From f3ea976cba8743a87efeb9fbca717309e3d65c47 Mon Sep 17 00:00:00 2001 From: Soof Golan <83900570+soof-golan@users.noreply.github.com> Date: Mon, 4 May 2026 10:01:46 +0200 Subject: [PATCH 01/56] Fix a1111 typo in extra_model_paths.yaml (#2720) --- extra_model_paths.yaml.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extra_model_paths.yaml.example b/extra_model_paths.yaml.example index 34df01681..9c395c0b2 100644 --- a/extra_model_paths.yaml.example +++ b/extra_model_paths.yaml.example @@ -28,7 +28,7 @@ #config for a1111 ui #all you have to do is uncomment this (remove the #) and change the base_path to where yours is installed -#a111: +#a1111: # base_path: path/to/stable-diffusion-webui/ # checkpoints: models/Stable-diffusion # configs: models/Stable-diffusion From c33d26c283ea53b8ba3e42ef3dca1f03ddf4d7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jukka=20Sepp=C3=A4nen?= <40791699+kijai@users.noreply.github.com> Date: Mon, 4 May 2026 20:20:40 +0300 Subject: [PATCH 02/56] fix: Proper memory estimation for frame interpolation when not using dynamic VRAM (#13698) --- comfy_extras/frame_interpolation_models/film_net.py | 3 +++ comfy_extras/frame_interpolation_models/ifnet.py | 3 +++ comfy_extras/nodes_frame_interpolation.py | 11 ++++------- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/comfy_extras/frame_interpolation_models/film_net.py b/comfy_extras/frame_interpolation_models/film_net.py index cf4f6e1e1..36bc79dc3 100644 --- a/comfy_extras/frame_interpolation_models/film_net.py +++ b/comfy_extras/frame_interpolation_models/film_net.py @@ -199,6 +199,9 @@ class FILMNet(nn.Module): def get_dtype(self): return self.extract.extract_sublevels.convs[0][0].conv.weight.dtype + def memory_used_forward(self, shape, dtype): + return 1700 * shape[1] * shape[2] * dtype.itemsize + def _build_warp_grids(self, H, W, device): """Pre-compute warp grids for all pyramid levels.""" if (H, W) in self._warp_grids: diff --git a/comfy_extras/frame_interpolation_models/ifnet.py b/comfy_extras/frame_interpolation_models/ifnet.py index 03cb34c50..ad6edbec9 100644 --- a/comfy_extras/frame_interpolation_models/ifnet.py +++ b/comfy_extras/frame_interpolation_models/ifnet.py @@ -74,6 +74,9 @@ class IFNet(nn.Module): def get_dtype(self): return self.encode.cnn0.weight.dtype + def memory_used_forward(self, shape, dtype): + return 300 * shape[1] * shape[2] * dtype.itemsize + def _build_warp_grids(self, H, W, device): if (H, W) in self._warp_grids: return diff --git a/comfy_extras/nodes_frame_interpolation.py b/comfy_extras/nodes_frame_interpolation.py index a3b00d36e..fa49c203a 100644 --- a/comfy_extras/nodes_frame_interpolation.py +++ b/comfy_extras/nodes_frame_interpolation.py @@ -37,7 +37,7 @@ class FrameInterpolationModelLoader(io.ComfyNode): model = cls._detect_and_load(sd) dtype = torch.float16 if model_management.should_use_fp16(model_management.get_torch_device()) else torch.float32 model.eval().to(dtype) - patcher = comfy.model_patcher.ModelPatcher( + patcher = comfy.model_patcher.CoreModelPatcher( model, load_device=model_management.get_torch_device(), offload_device=model_management.unet_offload_device(), @@ -98,16 +98,13 @@ class FrameInterpolate(io.ComfyNode): if num_frames < 2 or multiplier < 2: return io.NodeOutput(images) - model_management.load_model_gpu(interp_model) device = interp_model.load_device dtype = interp_model.model_dtype() inference_model = interp_model.model - - # Free VRAM for inference activations (model weights + ~20x a single frame's worth) - H, W = images.shape[1], images.shape[2] - activation_mem = H * W * 3 * images.element_size() * 20 - model_management.free_memory(activation_mem, device) + activation_mem = inference_model.memory_used_forward(images.shape, dtype) + model_management.load_models_gpu([interp_model], memory_required=activation_mem) align = getattr(inference_model, "pad_align", 1) + H, W = images.shape[1], images.shape[2] # Prepare a single padded frame on device for determining output dimensions def prepare_frame(idx): From c47633f3befbc32bf5aeece6a899c20d55a9feb1 Mon Sep 17 00:00:00 2001 From: rattus <46076784+rattus128@users.noreply.github.com> Date: Tue, 5 May 2026 05:56:05 +1000 Subject: [PATCH 03/56] prefetch: guard against no offload (#13703) cast_ will return no stream if there is no work to do. guard against this is the consume logic. --- comfy/model_prefetch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/comfy/model_prefetch.py b/comfy/model_prefetch.py index 0ad35deb5..72e11dec6 100644 --- a/comfy/model_prefetch.py +++ b/comfy/model_prefetch.py @@ -37,7 +37,8 @@ def prefetch_queue_pop(queue, device, module): consumed = queue.pop(0) if consumed is not None: offload_stream, prefetch_state = consumed - offload_stream.wait_stream(comfy.model_management.current_stream(device)) + if offload_stream is not None: + offload_stream.wait_stream(comfy.model_management.current_stream(device)) _, comfy_modules = prefetch_state if comfy_modules is not None: cleanup_prefetched_modules(comfy_modules) From 1ac78180b3f797f09f5805e5a923debe77638889 Mon Sep 17 00:00:00 2001 From: rattus <46076784+rattus128@users.noreply.github.com> Date: Tue, 5 May 2026 05:58:06 +1000 Subject: [PATCH 04/56] make control-net load order deterministic (#13701) Make this deterministic so speeds dont change base of load order. Load them in reverse order so whatever the caller lists first is the top priority. --- comfy/model_management.py | 10 ++++++---- comfy/sampler_helpers.py | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/comfy/model_management.py b/comfy/model_management.py index 02ad66656..21738a4c7 100644 --- a/comfy/model_management.py +++ b/comfy/model_management.py @@ -721,13 +721,15 @@ def load_models_gpu(models, memory_required=0, force_patch_weights=False, minimu else: minimum_memory_required = max(inference_memory, minimum_memory_required + extra_reserved_memory()) - models_temp = set() + # Order-preserving dedup. A plain set() would randomize iteration order across runs + models_temp = {} for m in models: - models_temp.add(m) + models_temp[m] = None for mm in m.model_patches_models(): - models_temp.add(mm) + models_temp[mm] = None - models = models_temp + models = list(models_temp) + models.reverse() models_to_load = [] diff --git a/comfy/sampler_helpers.py b/comfy/sampler_helpers.py index bbba09e26..3782fd2d5 100644 --- a/comfy/sampler_helpers.py +++ b/comfy/sampler_helpers.py @@ -89,7 +89,8 @@ def get_additional_models(conds, dtype): gligen += get_models_from_cond(conds[k], "gligen") add_models += get_models_from_cond(conds[k], "additional_models") - control_nets = set(cnets) + # Order-preserving dedup. A plain set() would randomize iteration order across runs + control_nets = list(dict.fromkeys(cnets)) inference_memory = 0 control_models = [] From 1265955b34dd63622caecb71fdae550fe5cb44fb Mon Sep 17 00:00:00 2001 From: rattus <46076784+rattus128@users.noreply.github.com> Date: Tue, 5 May 2026 09:40:57 +1000 Subject: [PATCH 05/56] ops: handle multi-compute of the same weight (#13705) If the same weight is used multiple times within the same prefetch window, it should only apply compute state mutations once. Mark the weight as fully resident on the first pass accordingly. --- comfy/ops.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/comfy/ops.py b/comfy/ops.py index 4f0338346..585c185a3 100644 --- a/comfy/ops.py +++ b/comfy/ops.py @@ -253,6 +253,9 @@ def resolve_cast_module_with_vbar(s, dtype, device, bias_dtype, compute_dtype, w if bias is not None: bias = post_cast(s, "bias", bias, bias_dtype, prefetch["resident"], update_weight) + if prefetch["signature"] is not None: + prefetch["resident"] = True + return weight, bias From 15a4494a4e5299e1210ccad6d49d3253555ef3e6 Mon Sep 17 00:00:00 2001 From: Alexis Rolland Date: Tue, 5 May 2026 08:37:25 +0800 Subject: [PATCH 06/56] chore: Update display names and categories (CORE-151) (#13693) * Standardize DEPRECATED label in node display name * Promote category image/video to root level video/ * Update images and masks names and categories --- comfy_api_nodes/nodes_sora.py | 2 +- comfy_extras/nodes_frame_interpolation.py | 2 +- comfy_extras/nodes_image_compare.py | 2 +- comfy_extras/nodes_images.py | 17 ++++++++++----- comfy_extras/nodes_mask.py | 9 ++++++-- comfy_extras/nodes_morphology.py | 6 ++++-- comfy_extras/nodes_post_processing.py | 6 ++++-- comfy_extras/nodes_video.py | 13 ++++++------ nodes.py | 25 ++++++++++++----------- 9 files changed, 50 insertions(+), 32 deletions(-) diff --git a/comfy_api_nodes/nodes_sora.py b/comfy_api_nodes/nodes_sora.py index 4d9075dcf..c1d485188 100644 --- a/comfy_api_nodes/nodes_sora.py +++ b/comfy_api_nodes/nodes_sora.py @@ -33,7 +33,7 @@ class OpenAIVideoSora2(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="OpenAIVideoSora2", - display_name="OpenAI Sora - Video (Deprecated)", + display_name="OpenAI Sora - Video (DEPRECATED)", category="api node/video/Sora", description=( "OpenAI video and audio generation.\n\n" diff --git a/comfy_extras/nodes_frame_interpolation.py b/comfy_extras/nodes_frame_interpolation.py index fa49c203a..9dd34cfb8 100644 --- a/comfy_extras/nodes_frame_interpolation.py +++ b/comfy_extras/nodes_frame_interpolation.py @@ -78,7 +78,7 @@ class FrameInterpolate(io.ComfyNode): return io.Schema( node_id="FrameInterpolate", display_name="Frame Interpolate", - category="image/video", + category="video", search_aliases=["rife", "film", "frame interpolation", "slow motion", "interpolate frames", "vfi"], inputs=[ FrameInterpolationModel.Input("interp_model"), diff --git a/comfy_extras/nodes_image_compare.py b/comfy_extras/nodes_image_compare.py index 3d943be67..58af9ae82 100644 --- a/comfy_extras/nodes_image_compare.py +++ b/comfy_extras/nodes_image_compare.py @@ -11,7 +11,7 @@ class ImageCompare(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="ImageCompare", - display_name="Image Compare", + display_name="Compare Images", description="Compares two images side by side with a slider.", category="image", essentials_category="Image Tools", diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index a77f0641f..68916ab75 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -24,7 +24,7 @@ class ImageCrop(IO.ComfyNode): return IO.Schema( node_id="ImageCrop", search_aliases=["trim"], - display_name="Image Crop (Deprecated)", + display_name="Crop Image (DEPRECATED)", category="image/transform", is_deprecated=True, essentials_category="Image Tools", @@ -56,7 +56,7 @@ class ImageCropV2(IO.ComfyNode): return IO.Schema( node_id="ImageCropV2", search_aliases=["trim"], - display_name="Image Crop", + display_name="Crop Image", category="image/transform", essentials_category="Image Tools", has_intermediate_output=True, @@ -109,6 +109,7 @@ class RepeatImageBatch(IO.ComfyNode): return IO.Schema( node_id="RepeatImageBatch", search_aliases=["duplicate image", "clone image"], + display_name="Repeat Image Batch", category="image/batch", inputs=[ IO.Image.Input("image"), @@ -131,6 +132,7 @@ class ImageFromBatch(IO.ComfyNode): return IO.Schema( node_id="ImageFromBatch", search_aliases=["select image", "pick from batch", "extract image"], + display_name="Get Image from Batch", category="image/batch", inputs=[ IO.Image.Input("image"), @@ -157,7 +159,8 @@ class ImageAddNoise(IO.ComfyNode): return IO.Schema( node_id="ImageAddNoise", search_aliases=["film grain"], - category="image", + display_name="Add Noise to Image", + category="image/postprocessing", inputs=[ IO.Image.Input("image"), IO.Int.Input( @@ -259,7 +262,7 @@ class ImageStitch(IO.ComfyNode): return IO.Schema( node_id="ImageStitch", search_aliases=["combine images", "join images", "concatenate images", "side by side"], - display_name="Image Stitch", + display_name="Stitch Images", description="Stitches image2 to image1 in the specified direction.\n" "If image2 is not provided, returns image1 unchanged.\n" "Optional spacing can be added between images.", @@ -434,6 +437,7 @@ class ResizeAndPadImage(IO.ComfyNode): return IO.Schema( node_id="ResizeAndPadImage", search_aliases=["fit to size"], + display_name="Resize And Pad Image", category="image/transform", inputs=[ IO.Image.Input("image"), @@ -485,6 +489,7 @@ class SaveSVGNode(IO.ComfyNode): return IO.Schema( node_id="SaveSVGNode", search_aliases=["export vector", "save vector graphics"], + display_name="Save SVG", description="Save SVG files on disk.", category="image/save", inputs=[ @@ -591,7 +596,7 @@ class ImageRotate(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="ImageRotate", - display_name="Image Rotate", + display_name="Rotate Image", search_aliases=["turn", "flip orientation"], category="image/transform", essentials_category="Image Tools", @@ -624,6 +629,7 @@ class ImageFlip(IO.ComfyNode): return IO.Schema( node_id="ImageFlip", search_aliases=["mirror", "reflect"], + display_name="Flip Image", category="image/transform", inputs=[ IO.Image.Input("image"), @@ -650,6 +656,7 @@ class ImageScaleToMaxDimension(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="ImageScaleToMaxDimension", + display_name="Scale Image to Max Dimension", category="image/upscaling", inputs=[ IO.Image.Input("image"), diff --git a/comfy_extras/nodes_mask.py b/comfy_extras/nodes_mask.py index 8ca947718..43a933dac 100644 --- a/comfy_extras/nodes_mask.py +++ b/comfy_extras/nodes_mask.py @@ -80,7 +80,8 @@ class ImageCompositeMasked(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="ImageCompositeMasked", - search_aliases=["paste image", "overlay", "layer"], + search_aliases=["overlay", "layer", "paste image", "images composition"], + display_name="Image Composite Masked", category="image", inputs=[ IO.Image.Input("destination"), @@ -201,6 +202,7 @@ class InvertMask(IO.ComfyNode): return IO.Schema( node_id="InvertMask", search_aliases=["reverse mask", "flip mask"], + display_name="Invert Mask", category="mask", inputs=[ IO.Mask.Input("mask"), @@ -222,6 +224,7 @@ class CropMask(IO.ComfyNode): return IO.Schema( node_id="CropMask", search_aliases=["cut mask", "extract mask region", "mask slice"], + display_name="Crop Mask", category="mask", inputs=[ IO.Mask.Input("mask"), @@ -247,7 +250,8 @@ class MaskComposite(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="MaskComposite", - search_aliases=["combine masks", "blend masks", "layer masks"], + search_aliases=["combine masks", "blend masks", "layer masks", "masks composition"], + display_name="Combine Masks", category="mask", inputs=[ IO.Mask.Input("destination"), @@ -298,6 +302,7 @@ class FeatherMask(IO.ComfyNode): return IO.Schema( node_id="FeatherMask", search_aliases=["soft edge mask", "blur mask edges", "gradient mask edge"], + display_name="Feather Mask", category="mask", inputs=[ IO.Mask.Input("mask"), diff --git a/comfy_extras/nodes_morphology.py b/comfy_extras/nodes_morphology.py index 4ab2fb7e8..c01b9436d 100644 --- a/comfy_extras/nodes_morphology.py +++ b/comfy_extras/nodes_morphology.py @@ -59,7 +59,8 @@ class ImageRGBToYUV(io.ComfyNode): return io.Schema( node_id="ImageRGBToYUV", search_aliases=["color space conversion"], - category="image/batch", + display_name="Image RGB to YUV", + category="image/color", inputs=[ io.Image.Input("image"), ], @@ -81,7 +82,8 @@ class ImageYUVToRGB(io.ComfyNode): return io.Schema( node_id="ImageYUVToRGB", search_aliases=["color space conversion"], - category="image/batch", + display_name="Image YUV to RGB", + category="image/color", inputs=[ io.Image.Input("Y"), io.Image.Input("U"), diff --git a/comfy_extras/nodes_post_processing.py b/comfy_extras/nodes_post_processing.py index 345fdb695..d938a2035 100644 --- a/comfy_extras/nodes_post_processing.py +++ b/comfy_extras/nodes_post_processing.py @@ -20,7 +20,8 @@ class Blend(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="ImageBlend", - display_name="Image Blend", + search_aliases=["mix images"], + display_name="Blend Images", category="image/postprocessing", essentials_category="Image Tools", inputs=[ @@ -224,6 +225,7 @@ class ImageScaleToTotalPixels(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="ImageScaleToTotalPixels", + display_name="Scale Image to Total Pixels", category="image/upscaling", inputs=[ io.Image.Input("image"), @@ -568,7 +570,7 @@ class BatchImagesNode(io.ComfyNode): return io.Schema( node_id="BatchImagesNode", display_name="Batch Images", - category="image", + category="image/batch", essentials_category="Image Tools", search_aliases=["batch", "image batch", "batch images", "combine images", "merge images", "stack images"], inputs=[ diff --git a/comfy_extras/nodes_video.py b/comfy_extras/nodes_video.py index 5c096c232..719acf2f1 100644 --- a/comfy_extras/nodes_video.py +++ b/comfy_extras/nodes_video.py @@ -17,7 +17,8 @@ class SaveWEBM(io.ComfyNode): return io.Schema( node_id="SaveWEBM", search_aliases=["export webm"], - category="image/video", + display_name="Save WEBM", + category="video", is_experimental=True, inputs=[ io.Image.Input("images"), @@ -72,7 +73,7 @@ class SaveVideo(io.ComfyNode): node_id="SaveVideo", search_aliases=["export video"], display_name="Save Video", - category="image/video", + category="video", essentials_category="Basics", description="Saves the input images to your ComfyUI output directory.", inputs=[ @@ -121,7 +122,7 @@ class CreateVideo(io.ComfyNode): node_id="CreateVideo", search_aliases=["images to video"], display_name="Create Video", - category="image/video", + category="video", description="Create a video from images.", inputs=[ io.Image.Input("images", tooltip="The images to create a video from."), @@ -146,7 +147,7 @@ class GetVideoComponents(io.ComfyNode): node_id="GetVideoComponents", search_aliases=["extract frames", "split video", "video to images", "demux"], display_name="Get Video Components", - category="image/video", + category="video", description="Extracts all components from a video: frames, audio, and framerate.", inputs=[ io.Video.Input("video", tooltip="The video to extract components from."), @@ -174,7 +175,7 @@ class LoadVideo(io.ComfyNode): node_id="LoadVideo", search_aliases=["import video", "open video", "video file"], display_name="Load Video", - category="image/video", + category="video", essentials_category="Basics", inputs=[ io.Combo.Input("file", options=sorted(files), upload=io.UploadType.video), @@ -216,7 +217,7 @@ class VideoSlice(io.ComfyNode): "frame load cap", "start time", ], - category="image/video", + category="video", essentials_category="Video Tools", inputs=[ io.Video.Input("video"), diff --git a/nodes.py b/nodes.py index 8f8f90cf6..eebc2fa76 100644 --- a/nodes.py +++ b/nodes.py @@ -1887,7 +1887,7 @@ class ImageInvert: RETURN_TYPES = ("IMAGE",) FUNCTION = "invert" - CATEGORY = "image" + CATEGORY = "image/color" def invert(self, image): s = 1.0 - image @@ -1903,7 +1903,7 @@ class ImageBatch: RETURN_TYPES = ("IMAGE",) FUNCTION = "batch" - CATEGORY = "image" + CATEGORY = "image/batch" DEPRECATED = True def batch(self, image1, image2): @@ -1960,7 +1960,7 @@ class ImagePadForOutpaint: RETURN_TYPES = ("IMAGE", "MASK") FUNCTION = "expand_image" - CATEGORY = "image" + CATEGORY = "image/transform" def expand_image(self, image, left, top, right, bottom, feathering): d1, d2, d3, d4 = image.size() @@ -2103,7 +2103,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "ConditioningSetArea": "Conditioning (Set Area)", "ConditioningSetAreaPercentage": "Conditioning (Set Area with Percentage)", "ConditioningSetMask": "Conditioning (Set Mask)", - "ControlNetApply": "Apply ControlNet (OLD)", + "ControlNetApply": "Apply ControlNet (DEPRECATED)", "ControlNetApplyAdvanced": "Apply ControlNet", # Latent "VAEEncodeForInpaint": "VAE Encode (for Inpainting)", @@ -2121,6 +2121,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "LatentFromBatch" : "Latent From Batch", "RepeatLatentBatch": "Repeat Latent Batch", # Image + "EmptyImage": "Empty Image", "SaveImage": "Save Image", "PreviewImage": "Preview Image", "LoadImage": "Load Image", @@ -2128,15 +2129,15 @@ NODE_DISPLAY_NAME_MAPPINGS = { "LoadImageOutput": "Load Image (from Outputs)", "ImageScale": "Upscale Image", "ImageScaleBy": "Upscale Image By", - "ImageInvert": "Invert Image", + "ImageInvert": "Invert Image Colors", "ImagePadForOutpaint": "Pad Image for Outpainting", - "ImageBatch": "Batch Images", - "ImageCrop": "Image Crop", - "ImageStitch": "Image Stitch", - "ImageBlend": "Image Blend", - "ImageBlur": "Image Blur", - "ImageQuantize": "Image Quantize", - "ImageSharpen": "Image Sharpen", + "ImageBatch": "Batch Images (DEPRECATED)", + "ImageCrop": "Crop Image", + "ImageStitch": "Stitch Images", + "ImageBlend": "Blend Images", + "ImageBlur": "Blur Image", + "ImageQuantize": "Quantize Image", + "ImageSharpen": "Sharpen Image", "ImageScaleToTotalPixels": "Scale Image to Total Pixels", "GetImageSize": "Get Image Size", # _for_testing From 35819e35a8f55425ffc23a3e901256cc22bad724 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Mon, 4 May 2026 18:28:21 -0700 Subject: [PATCH 07/56] fix(spec): mark DeviceStats.index and NodeInfo.essentials_category as nullable (#13706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(spec): mark DeviceStats.index and NodeInfo.essentials_category as nullable Two fields in openapi.yaml are declared as required/non-nullable but the Python implementation legitimately returns `null` for them, so any client that response-validates against the spec will fail. `DeviceStats.index` (used by GET /api/system_stats): - server.py emits `"index": device.index` unconditionally - For the CPU device (--cpu mode), `torch.device("cpu").index` is `None` - → JSON response includes `"index": null` for CPU devices `NodeInfo.essentials_category` (used by GET /api/object_info): - The V3 schema-based path (comfy_api/latest/_io.py:1654) unconditionally passes `essentials_category=self.essentials_category` into NodeInfoV1 and serializes via dataclasses.asdict(), so the key is always present - Schema's `essentials_category` defaults to `None` for nodes that don't set it in `define_schema` (e.g. the APG node) - → JSON response includes `"essentials_category": null` for those nodes - (The V1 path in server.py uses `hasattr` and so omits the key entirely when not set, but the V3 path is the one that produces nulls) Both fields keep their existing `required` status — they're always present in the response, the value is just nullable. Descriptions expanded to spell out when `null` is expected. * docs(spec): clarify essentials_category presence rules The previous description said "null for nodes that don't set ESSENTIALS_CATEGORY (V1)" — that's wrong. server.py:739-740 uses `hasattr` and OMITS the key when the V1 attribute isn't defined; null only happens if the attribute is explicitly set to None. Spell out all three legal shapes (string / null / absent) and which path produces which. --- openapi.yaml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 77d0e2318..3b602e0f6 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2347,7 +2347,12 @@ components: description: Device type (cuda, mps, cpu, etc.) index: type: number - description: Device index + nullable: true + description: | + Device index within its type (e.g. CUDA ordinal for `cuda:0`, + `cuda:1`). `null` for devices with no index, including the CPU + device returned in `--cpu` mode (PyTorch's `torch.device('cpu').index` + is `None`). vram_total: type: number description: Total VRAM in bytes @@ -2503,7 +2508,18 @@ components: description: Alternative search terms for finding this node essentials_category: type: string - description: Category override used by the essentials pack + nullable: true + description: | + Category override used by the essentials pack. The + `essentials_category` key may be present with a string value, + present and `null`, or absent entirely: + + - V1 nodes: `essentials_category` is **omitted** when the node + class doesn't define an `ESSENTIALS_CATEGORY` attribute, and + **`null`** if the attribute is explicitly set to `None`. + - V3 nodes (`comfy_api.latest.io`): `essentials_category` is + **always present**, and **`null`** for nodes whose `Schema` + doesn't populate it. # ------------------------------------------------------------------- # Models From 413e250ccd04d830c3fa27f8b1957885ea0b8e1b Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Mon, 4 May 2026 18:59:48 -0700 Subject: [PATCH 08/56] spec: add workflow_id / workflow_version_id to PromptRequest with x-runtime tag (#13709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two optional, nullable UUID fields to PromptRequest for runtimes that wrap workflow execution in a workflow-version entity (the hosted-cloud runtime does this; local ComfyUI does not). Both fields are tagged `x-runtime: [cloud]` to mark them as runtime-specific — local ComfyUI returns `null` (or omits them entirely) and that's correct behavior, not drift. ## Why these fields belong in the OSS spec Hosted-cloud's frontend and backend share `openapi.yaml` as their single source of truth via auto-generated client types. Without the fields declared in the spec, the cloud runtime has to either: 1. Hand-edit a vendored copy of openapi.yaml (drift between vendor and upstream — unsustainable). 2. Maintain a separate cloud-only spec file (forks the contract, defeats the point of a shared OSS spec). Both options have been tried and both produce maintenance pain. The shape that scales is: cloud-only fields live in OSS spec under their intended path, declared nullable, with an explicit `x-runtime` tag so local-only readers can ignore them programmatically and human readers can see what each runtime populates. ## About the `x-runtime` extension This is the first use of `x-runtime` in this spec. Convention: - `x-runtime: [cloud]` — only the hosted-cloud runtime populates the field; local returns null or omits. - `x-runtime: [local]` — only local populates; cloud returns null. - Tag absent — both runtimes populate the field (the default). This is a vendor extension (`x-` prefix) and is ignored by spec validators that don't recognize it, including `kin-openapi`. Local clients reading the spec see two extra optional nullable fields, which is forward-compatible with all existing readers. ## What this does not change - No Python code changes. `PromptRequest` already accepts arbitrary optional fields (`extra_data: additionalProperties: true` on the same schema is a stronger guarantee). The Python server already silently accepts and ignores both fields today. - No required-fields change. Both fields stay outside `required`, so older clients that don't know about them keep validating. - No nullability widening on existing fields. ## Verification - YAML parses (`yaml.safe_load`). - `kin-openapi` `loader.LoadFromFile` accepts the modified spec. - `openapi3filter.ValidateRequest` on a PromptRequest with both fields set to `null`, set to a valid UUID, or omitted — all pass. --- openapi.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index 3b602e0f6..30f85b6ad 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1999,6 +1999,26 @@ components: items: type: string description: List of node IDs to execute (partial graph execution) + workflow_id: + type: string + format: uuid + nullable: true + x-runtime: [cloud] + description: | + UUID identifying a hosted-cloud workflow entity to associate with this + job. Local ComfyUI doesn't track workflow entities and returns `null` + (or omits the field). The `x-runtime: [cloud]` extension marks this + as populated only by the hosted-cloud runtime; absence of the tag + means a field is populated by all runtimes. + workflow_version_id: + type: string + format: uuid + nullable: true + x-runtime: [cloud] + description: | + UUID identifying a hosted-cloud workflow version to associate with + this job. Local ComfyUI returns `null` (or omits the field). See + `workflow_id` above for `x-runtime` semantics. PromptResponse: type: object From ae457da84bfaf074d68b73881c565e3dd2b20b98 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 4 May 2026 19:50:26 -0700 Subject: [PATCH 09/56] feat: add generic --feature-flag CLI arg and --list-feature-flags registry (#13685) --- comfy/cli_args.py | 2 + comfy_api/feature_flags.py | 92 +++++++++++++++++++++++++++++++- main.py | 10 +++- tests-unit/feature_flags_test.py | 85 +++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 3 deletions(-) diff --git a/comfy/cli_args.py b/comfy/cli_args.py index d2fde8b67..9dadb0093 100644 --- a/comfy/cli_args.py +++ b/comfy/cli_args.py @@ -238,6 +238,8 @@ database_default_path = os.path.abspath( ) parser.add_argument("--database-url", type=str, default=f"sqlite:///{database_default_path}", help="Specify the database URL, e.g. for an in-memory database you can use 'sqlite:///:memory:'.") parser.add_argument("--enable-assets", action="store_true", help="Enable the assets system (API routes, database synchronization, and background scanning).") +parser.add_argument("--feature-flag", type=str, action='append', default=[], metavar="KEY[=VALUE]", help="Set a server feature flag. Use KEY=VALUE to set an explicit value, or bare KEY to set it to true. Can be specified multiple times. Boolean values (true/false) and numbers are auto-converted. Examples: --feature-flag show_signin_button=true or --feature-flag show_signin_button") +parser.add_argument("--list-feature-flags", action="store_true", help="Print the registry of known CLI-settable feature flags as JSON and exit.") if comfy.options.args_parsing: args = parser.parse_args() diff --git a/comfy_api/feature_flags.py b/comfy_api/feature_flags.py index 9f6918315..adb5a3144 100644 --- a/comfy_api/feature_flags.py +++ b/comfy_api/feature_flags.py @@ -5,12 +5,95 @@ This module handles capability negotiation between frontend and backend, allowing graceful protocol evolution while maintaining backward compatibility. """ -from typing import Any +import logging +from typing import Any, TypedDict from comfy.cli_args import args + +class FeatureFlagInfo(TypedDict): + type: str + default: Any + description: str + + +# Registry of known CLI-settable feature flags. +# Launchers can query this via --list-feature-flags to discover valid flags. +CLI_FEATURE_FLAG_REGISTRY: dict[str, FeatureFlagInfo] = { + "show_signin_button": { + "type": "bool", + "default": False, + "description": "Show the sign-in button in the frontend even when not signed in", + }, +} + + +def _coerce_bool(v: str) -> bool: + """Strict bool coercion: only 'true'/'false' (case-insensitive). + + Anything else raises ValueError so the caller can warn and drop the flag, + rather than silently treating typos like 'ture' or 'yes' as False. + """ + lower = v.lower() + if lower == "true": + return True + if lower == "false": + return False + raise ValueError(f"expected 'true' or 'false', got {v!r}") + + +_COERCE_FNS: dict[str, Any] = { + "bool": _coerce_bool, + "int": lambda v: int(v), + "float": lambda v: float(v), +} + + +def _coerce_flag_value(key: str, raw_value: str) -> Any: + """Coerce a raw string value using the registry type, or keep as string. + + Returns the raw string if the key is unregistered or the type is unknown. + Raises ValueError/TypeError if the key is registered with a known type but + the value cannot be coerced; callers are expected to warn and drop the flag. + """ + info = CLI_FEATURE_FLAG_REGISTRY.get(key) + if info is None: + return raw_value + coerce = _COERCE_FNS.get(info["type"]) + if coerce is None: + return raw_value + return coerce(raw_value) + + +def _parse_cli_feature_flags() -> dict[str, Any]: + """Parse --feature-flag key=value pairs from CLI args into a dict. + + Items without '=' default to the value 'true' (bare flag form). + Flags whose value cannot be coerced to the registered type are dropped + with a warning, so a typo like '--feature-flag some_bool=ture' does not + silently take effect as the wrong value. + """ + result: dict[str, Any] = {} + for item in getattr(args, "feature_flag", []): + key, sep, raw_value = item.partition("=") + key = key.strip() + if not key: + continue + if not sep: + raw_value = "true" + try: + result[key] = _coerce_flag_value(key, raw_value.strip()) + except (ValueError, TypeError) as e: + info = CLI_FEATURE_FLAG_REGISTRY.get(key, {}) + logging.warning( + "Could not coerce --feature-flag %s=%r to %s (%s); dropping flag.", + key, raw_value.strip(), info.get("type", "?"), e, + ) + return result + + # Default server capabilities -SERVER_FEATURE_FLAGS: dict[str, Any] = { +_CORE_FEATURE_FLAGS: dict[str, Any] = { "supports_preview_metadata": True, "max_upload_size": args.max_upload_size * 1024 * 1024, # Convert MB to bytes "extension": {"manager": {"supports_v4": True}}, @@ -18,6 +101,11 @@ SERVER_FEATURE_FLAGS: dict[str, Any] = { "assets": args.enable_assets, } +# CLI-provided flags cannot overwrite core flags +_cli_flags = {k: v for k, v in _parse_cli_feature_flags().items() if k not in _CORE_FEATURE_FLAGS} + +SERVER_FEATURE_FLAGS: dict[str, Any] = {**_CORE_FEATURE_FLAGS, **_cli_flags} + def get_connection_feature( sockets_metadata: dict[str, dict[str, Any]], diff --git a/main.py b/main.py index dbaf2745c..a6fdaf43c 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,21 @@ import comfy.options comfy.options.enable_args_parsing() +from comfy.cli_args import args + +if args.list_feature_flags: + import json + from comfy_api.feature_flags import CLI_FEATURE_FLAG_REGISTRY + print(json.dumps(CLI_FEATURE_FLAG_REGISTRY, indent=2)) # noqa: T201 + raise SystemExit(0) + import os import importlib.util import shutil import importlib.metadata import folder_paths import time -from comfy.cli_args import args, enables_dynamic_vram +from comfy.cli_args import enables_dynamic_vram from app.logger import setup_logger setup_logger(log_level=args.verbose, use_stdout=args.log_stdout) diff --git a/tests-unit/feature_flags_test.py b/tests-unit/feature_flags_test.py index f2702cfc8..8ec52a124 100644 --- a/tests-unit/feature_flags_test.py +++ b/tests-unit/feature_flags_test.py @@ -1,10 +1,15 @@ """Tests for feature flags functionality.""" +import pytest + from comfy_api.feature_flags import ( get_connection_feature, supports_feature, get_server_features, + CLI_FEATURE_FLAG_REGISTRY, SERVER_FEATURE_FLAGS, + _coerce_flag_value, + _parse_cli_feature_flags, ) @@ -96,3 +101,83 @@ class TestFeatureFlags: result = get_connection_feature(sockets_metadata, "sid1", "any_feature") assert result is False assert supports_feature(sockets_metadata, "sid1", "any_feature") is False + + +class TestCoerceFlagValue: + """Test suite for _coerce_flag_value.""" + + def test_registered_bool_true(self): + assert _coerce_flag_value("show_signin_button", "true") is True + assert _coerce_flag_value("show_signin_button", "True") is True + + def test_registered_bool_false(self): + assert _coerce_flag_value("show_signin_button", "false") is False + assert _coerce_flag_value("show_signin_button", "FALSE") is False + + def test_unregistered_key_stays_string(self): + assert _coerce_flag_value("unknown_flag", "true") == "true" + assert _coerce_flag_value("unknown_flag", "42") == "42" + + def test_bool_typo_raises(self): + """Strict bool: typos like 'ture' or 'yes' must raise so the flag can be dropped.""" + with pytest.raises(ValueError): + _coerce_flag_value("show_signin_button", "ture") + with pytest.raises(ValueError): + _coerce_flag_value("show_signin_button", "yes") + with pytest.raises(ValueError): + _coerce_flag_value("show_signin_button", "1") + with pytest.raises(ValueError): + _coerce_flag_value("show_signin_button", "") + + def test_failed_int_coercion_raises(self, monkeypatch): + """Malformed values for typed flags must raise; caller decides what to do.""" + monkeypatch.setitem( + CLI_FEATURE_FLAG_REGISTRY, + "test_int_flag", + {"type": "int", "default": 0, "description": "test"}, + ) + with pytest.raises(ValueError): + _coerce_flag_value("test_int_flag", "not_a_number") + + +class TestParseCliFeatureFlags: + """Test suite for _parse_cli_feature_flags.""" + + def test_single_flag(self, monkeypatch): + monkeypatch.setattr("comfy_api.feature_flags.args", type("Args", (), {"feature_flag": ["show_signin_button=true"]})()) + result = _parse_cli_feature_flags() + assert result == {"show_signin_button": True} + + def test_missing_equals_defaults_to_true(self, monkeypatch): + """Bare flag without '=' is treated as the string 'true' (and coerced if registered).""" + monkeypatch.setattr("comfy_api.feature_flags.args", type("Args", (), {"feature_flag": ["show_signin_button", "valid=1"]})()) + result = _parse_cli_feature_flags() + assert result == {"show_signin_button": True, "valid": "1"} + + def test_empty_key_skipped(self, monkeypatch): + monkeypatch.setattr("comfy_api.feature_flags.args", type("Args", (), {"feature_flag": ["=value", "valid=1"]})()) + result = _parse_cli_feature_flags() + assert result == {"valid": "1"} + + def test_invalid_bool_value_dropped(self, monkeypatch, caplog): + """A typo'd bool value must be dropped entirely, not silently set to False + and not stored as a raw string. A warning must be logged.""" + monkeypatch.setattr( + "comfy_api.feature_flags.args", + type("Args", (), {"feature_flag": ["show_signin_button=ture", "valid=1"]})(), + ) + with caplog.at_level("WARNING"): + result = _parse_cli_feature_flags() + assert result == {"valid": "1"} + assert "show_signin_button" not in result + assert any("show_signin_button" in r.message and "drop" in r.message.lower() for r in caplog.records) + + +class TestCliFeatureFlagRegistry: + """Test suite for the CLI feature flag registry.""" + + def test_registry_entries_have_required_fields(self): + for key, info in CLI_FEATURE_FLAG_REGISTRY.items(): + assert "type" in info, f"{key} missing 'type'" + assert "default" in info, f"{key} missing 'default'" + assert "description" in info, f"{key} missing 'description'" From e758594e3b93a0851018347a59bb0fd35f54205a Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 4 May 2026 20:17:56 -0700 Subject: [PATCH 10/56] Add deploy environment header (Comfy-Env) to partner node API calls (#13425) --- .gitignore | 1 + comfy/deploy_environment.py | 34 ++++++++ comfy_api_nodes/util/client.py | 3 + tests-unit/deploy_environment_test.py | 109 ++++++++++++++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 comfy/deploy_environment.py create mode 100644 tests-unit/deploy_environment_test.py diff --git a/.gitignore b/.gitignore index 0ab4ba75e..fc426eda4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ web_custom_versions/ .DS_Store filtered-openapi.yaml uv.lock +.comfy_environment diff --git a/comfy/deploy_environment.py b/comfy/deploy_environment.py new file mode 100644 index 000000000..8c99a3584 --- /dev/null +++ b/comfy/deploy_environment.py @@ -0,0 +1,34 @@ +import functools +import logging +import os + +logger = logging.getLogger(__name__) + +_DEFAULT_DEPLOY_ENV = "local-git" +_ENV_FILENAME = ".comfy_environment" + +# Resolve the ComfyUI install directory (the parent of this `comfy/` package). +# We deliberately avoid `folder_paths.base_path` here because that is overridden +# by the `--base-directory` CLI arg to a user-supplied path, whereas the +# `.comfy_environment` marker is written by launchers/installers next to the +# ComfyUI install itself. +_COMFY_INSTALL_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + +@functools.cache +def get_deploy_environment() -> str: + env_file = os.path.join(_COMFY_INSTALL_DIR, _ENV_FILENAME) + try: + with open(env_file, encoding="utf-8") as f: + # Cap the read so a malformed or maliciously crafted file (e.g. + # a single huge line with no newline) can't blow up memory. + first_line = f.readline(128).strip() + value = "".join(c for c in first_line if 32 <= ord(c) < 127) + if value: + return value + except FileNotFoundError: + pass + except Exception as e: + logger.error("Failed to read %s: %s", env_file, e) + + return _DEFAULT_DEPLOY_ENV diff --git a/comfy_api_nodes/util/client.py b/comfy_api_nodes/util/client.py index a0b8d35e1..8e1ba91ba 100644 --- a/comfy_api_nodes/util/client.py +++ b/comfy_api_nodes/util/client.py @@ -19,6 +19,8 @@ from comfy import utils from comfy_api.latest import IO from server import PromptServer +from comfy.deploy_environment import get_deploy_environment + from . import request_logger from ._helpers import ( default_base_url, @@ -624,6 +626,7 @@ async def _request_base(cfg: _RequestConfig, expect_binary: bool): payload_headers = {"Accept": "*/*"} if expect_binary else {"Accept": "application/json"} if not parsed_url.scheme and not parsed_url.netloc: # is URL relative? payload_headers.update(get_auth_header(cfg.node_cls)) + payload_headers["Comfy-Env"] = get_deploy_environment() if cfg.endpoint.headers: payload_headers.update(cfg.endpoint.headers) diff --git a/tests-unit/deploy_environment_test.py b/tests-unit/deploy_environment_test.py new file mode 100644 index 000000000..c3497fbb0 --- /dev/null +++ b/tests-unit/deploy_environment_test.py @@ -0,0 +1,109 @@ +"""Tests for comfy.deploy_environment.""" + +import os + +import pytest + +from comfy import deploy_environment +from comfy.deploy_environment import get_deploy_environment + + +@pytest.fixture(autouse=True) +def _reset_cache_and_install_dir(tmp_path, monkeypatch): + """Reset the functools cache and point the ComfyUI install dir at a tmp dir for each test.""" + get_deploy_environment.cache_clear() + monkeypatch.setattr(deploy_environment, "_COMFY_INSTALL_DIR", str(tmp_path)) + yield + get_deploy_environment.cache_clear() + + +def _write_env_file(tmp_path, content: str) -> str: + """Write the env file with exact content (no newline translation). + + `newline=""` disables Python's text-mode newline translation so the bytes + on disk match the literal string passed in, regardless of host OS. + Newline-style tests (CRLF, lone CR) rely on this. + """ + path = os.path.join(str(tmp_path), ".comfy_environment") + with open(path, "w", encoding="utf-8", newline="") as f: + f.write(content) + return path + + +class TestGetDeployEnvironment: + def test_returns_local_git_when_file_missing(self): + assert get_deploy_environment() == "local-git" + + def test_reads_value_from_file(self, tmp_path): + _write_env_file(tmp_path, "local-desktop2-standalone\n") + assert get_deploy_environment() == "local-desktop2-standalone" + + def test_strips_trailing_whitespace_and_newline(self, tmp_path): + _write_env_file(tmp_path, " local-desktop2-standalone \n") + assert get_deploy_environment() == "local-desktop2-standalone" + + def test_only_first_line_is_used(self, tmp_path): + _write_env_file(tmp_path, "first-line\nsecond-line\n") + assert get_deploy_environment() == "first-line" + + def test_crlf_line_ending(self, tmp_path): + # Windows editors often save text files with CRLF line endings. + # The CR must not end up in the returned value. + _write_env_file(tmp_path, "local-desktop2-standalone\r\n") + assert get_deploy_environment() == "local-desktop2-standalone" + + def test_crlf_multiline_only_first_line_used(self, tmp_path): + _write_env_file(tmp_path, "first-line\r\nsecond-line\r\n") + assert get_deploy_environment() == "first-line" + + def test_crlf_with_surrounding_whitespace(self, tmp_path): + _write_env_file(tmp_path, " local-desktop2-standalone \r\n") + assert get_deploy_environment() == "local-desktop2-standalone" + + def test_lone_cr_line_ending(self, tmp_path): + # Classic-Mac / some legacy editors use a bare CR. + # Universal-newlines decoding treats it as a line terminator too. + _write_env_file(tmp_path, "local-desktop2-standalone\r") + assert get_deploy_environment() == "local-desktop2-standalone" + + def test_empty_file_falls_back_to_default(self, tmp_path): + _write_env_file(tmp_path, "") + assert get_deploy_environment() == "local-git" + + def test_empty_after_whitespace_strip_falls_back_to_default(self, tmp_path): + _write_env_file(tmp_path, " \n") + assert get_deploy_environment() == "local-git" + + def test_strips_control_chars_within_first_line(self, tmp_path): + # Embedded NUL/control chars in the value should be stripped + # (header-injection / smuggling protection). + _write_env_file(tmp_path, "abc\x00\x07xyz\n") + assert get_deploy_environment() == "abcxyz" + + def test_strips_non_ascii_characters(self, tmp_path): + _write_env_file(tmp_path, "café-é\n") + assert get_deploy_environment() == "caf-" + + def test_caps_read_at_128_bytes(self, tmp_path): + # A single huge line with no newline must not be fully read into memory. + huge = "x" * 10_000 + _write_env_file(tmp_path, huge) + result = get_deploy_environment() + assert result == "x" * 128 + + def test_result_is_cached_across_calls(self, tmp_path): + path = _write_env_file(tmp_path, "first_value\n") + assert get_deploy_environment() == "first_value" + # Overwrite the file — cached value should still be returned. + with open(path, "w", encoding="utf-8") as f: + f.write("second_value\n") + assert get_deploy_environment() == "first_value" + + def test_unreadable_file_falls_back_to_default(self, tmp_path, monkeypatch): + _write_env_file(tmp_path, "should_not_be_used\n") + + def _boom(*args, **kwargs): + raise OSError("simulated read failure") + + monkeypatch.setattr("builtins.open", _boom) + assert get_deploy_environment() == "local-git" From 9aef025fb0c67c9982e6ea52e7a866fd23625c5e Mon Sep 17 00:00:00 2001 From: comfyanonymous <121283862+comfyanonymous@users.noreply.github.com> Date: Mon, 4 May 2026 20:45:48 -0700 Subject: [PATCH 11/56] Document core release frequency is now ~2 weeks. (#13710) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a3bd3ba0a..0fd317d0a 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Workflow examples can be found on the [Examples page](https://comfyanonymous.git ComfyUI follows a weekly release cycle targeting Monday but this regularly changes because of model releases or large changes to the codebase. There are three interconnected repositories: 1. **[ComfyUI Core](https://github.com/comfyanonymous/ComfyUI)** - - Releases a new stable version (e.g., v0.7.0) roughly every week. + - Releases a new major stable version (e.g., v0.7.0) roughly every 2 weeks. - Starting from v0.4.0 patch versions will be used for fixes backported onto the current stable release. - Minor versions will be used for releases off the master branch. - Patch versions may still be used for releases on the master branch in cases where a backport would not make sense. From fed8d5efa6b70d5b24c4c33cb643bfccc39d45b5 Mon Sep 17 00:00:00 2001 From: Talmaj Date: Tue, 5 May 2026 06:01:22 +0200 Subject: [PATCH 12/56] feat: Auto-regressive video generation (CORE-25) (#13082) --- comfy/k_diffusion/sampling.py | 99 ++++++++++++ comfy/ldm/wan/ar_model.py | 276 +++++++++++++++++++++++++++++++++ comfy/model_base.py | 8 + comfy/supported_models.py | 20 +++ comfy_extras/nodes_ar_video.py | 84 ++++++++++ nodes.py | 1 + 6 files changed, 488 insertions(+) create mode 100644 comfy/ldm/wan/ar_model.py create mode 100644 comfy_extras/nodes_ar_video.py diff --git a/comfy/k_diffusion/sampling.py b/comfy/k_diffusion/sampling.py index 6978eb717..d33bc7199 100644 --- a/comfy/k_diffusion/sampling.py +++ b/comfy/k_diffusion/sampling.py @@ -1810,3 +1810,102 @@ def sample_sa_solver(model, x, sigmas, extra_args=None, callback=None, disable=F def sample_sa_solver_pece(model, x, sigmas, extra_args=None, callback=None, disable=False, tau_func=None, s_noise=1.0, noise_sampler=None, predictor_order=3, corrector_order=4, simple_order_2=False): """Stochastic Adams Solver with PECE (Predict–Evaluate–Correct–Evaluate) mode (NeurIPS 2023).""" return sample_sa_solver(model, x, sigmas, extra_args=extra_args, callback=callback, disable=disable, tau_func=tau_func, s_noise=s_noise, noise_sampler=noise_sampler, predictor_order=predictor_order, corrector_order=corrector_order, use_pece=True, simple_order_2=simple_order_2) + + +@torch.no_grad() +def sample_ar_video(model, x, sigmas, extra_args=None, callback=None, disable=None, + num_frame_per_block=1): + """ + Autoregressive video sampler: block-by-block denoising with KV cache + and flow-match re-noising for Causal Forcing / Self-Forcing models. + + Requires a Causal-WAN compatible model (diffusion_model must expose + init_kv_caches / init_crossattn_caches) and 5-D latents [B,C,T,H,W]. + + All AR-loop parameters are passed via the SamplerARVideo node, not read + from the checkpoint or transformer_options. + """ + extra_args = {} if extra_args is None else extra_args + model_options = extra_args.get("model_options", {}) + transformer_options = model_options.get("transformer_options", {}) + + if x.ndim != 5: + raise ValueError( + f"ar_video sampler requires 5-D video latents [B,C,T,H,W], got {x.ndim}-D tensor with shape {x.shape}. " + "This sampler is only compatible with autoregressive video models (e.g. Causal-WAN)." + ) + + inner_model = model.inner_model.inner_model + causal_model = inner_model.diffusion_model + + if not (hasattr(causal_model, "init_kv_caches") and hasattr(causal_model, "init_crossattn_caches")): + raise TypeError( + "ar_video sampler requires a Causal-WAN compatible model whose diffusion_model " + "exposes init_kv_caches() and init_crossattn_caches(). The loaded checkpoint " + "does not support this interface — choose a different sampler." + ) + + seed = extra_args.get("seed", 0) + + bs, c, lat_t, lat_h, lat_w = x.shape + frame_seq_len = -(-lat_h // 2) * -(-lat_w // 2) # ceiling division + num_blocks = -(-lat_t // num_frame_per_block) # ceiling division + device = x.device + model_dtype = inner_model.get_dtype() + + kv_caches = causal_model.init_kv_caches(bs, lat_t * frame_seq_len, device, model_dtype) + crossattn_caches = causal_model.init_crossattn_caches(bs, device, model_dtype) + + output = torch.zeros_like(x) + s_in = x.new_ones([x.shape[0]]) + current_start_frame = 0 + num_sigma_steps = len(sigmas) - 1 + total_real_steps = num_blocks * num_sigma_steps + step_count = 0 + + try: + for block_idx in trange(num_blocks, disable=disable): + bf = min(num_frame_per_block, lat_t - current_start_frame) + fs, fe = current_start_frame, current_start_frame + bf + noisy_input = x[:, :, fs:fe] + + ar_state = { + "start_frame": current_start_frame, + "kv_caches": kv_caches, + "crossattn_caches": crossattn_caches, + } + transformer_options["ar_state"] = ar_state + + for i in range(num_sigma_steps): + denoised = model(noisy_input, sigmas[i] * s_in, **extra_args) + + if callback is not None: + scaled_i = step_count * num_sigma_steps // total_real_steps + callback({"x": noisy_input, "i": scaled_i, "sigma": sigmas[i], + "sigma_hat": sigmas[i], "denoised": denoised}) + + if sigmas[i + 1] == 0: + noisy_input = denoised + else: + sigma_next = sigmas[i + 1] + torch.manual_seed(seed + block_idx * 1000 + i) + fresh_noise = torch.randn_like(denoised) + noisy_input = (1.0 - sigma_next) * denoised + sigma_next * fresh_noise + + for cache in kv_caches: + cache["end"] -= bf * frame_seq_len + + step_count += 1 + + output[:, :, fs:fe] = noisy_input + + for cache in kv_caches: + cache["end"] -= bf * frame_seq_len + zero_sigma = sigmas.new_zeros([1]) + _ = model(noisy_input, zero_sigma * s_in, **extra_args) + + current_start_frame += bf + finally: + transformer_options.pop("ar_state", None) + + return output diff --git a/comfy/ldm/wan/ar_model.py b/comfy/ldm/wan/ar_model.py new file mode 100644 index 000000000..d72f53602 --- /dev/null +++ b/comfy/ldm/wan/ar_model.py @@ -0,0 +1,276 @@ +""" +CausalWanModel: Wan 2.1 backbone with KV-cached causal self-attention for +autoregressive (frame-by-frame) video generation via Causal Forcing. + +Weight-compatible with the standard WanModel -- same layer names, same shapes. +The difference is purely in the forward pass: this model processes one temporal +block at a time and maintains a KV cache across blocks. + +Reference: https://github.com/thu-ml/Causal-Forcing +""" + +import torch +import torch.nn as nn + +from comfy.ldm.modules.attention import optimized_attention +from comfy.ldm.flux.math import apply_rope1 +from comfy.ldm.wan.model import ( + sinusoidal_embedding_1d, + repeat_e, + WanModel, + WanAttentionBlock, +) +import comfy.ldm.common_dit +import comfy.model_management + + +class CausalWanSelfAttention(nn.Module): + """Self-attention with KV cache support for autoregressive inference.""" + + def __init__(self, dim, num_heads, window_size=(-1, -1), qk_norm=True, + eps=1e-6, operation_settings={}): + assert dim % num_heads == 0 + super().__init__() + self.dim = dim + self.num_heads = num_heads + self.head_dim = dim // num_heads + self.qk_norm = qk_norm + self.eps = eps + + ops = operation_settings.get("operations") + device = operation_settings.get("device") + dtype = operation_settings.get("dtype") + + self.q = ops.Linear(dim, dim, device=device, dtype=dtype) + self.k = ops.Linear(dim, dim, device=device, dtype=dtype) + self.v = ops.Linear(dim, dim, device=device, dtype=dtype) + self.o = ops.Linear(dim, dim, device=device, dtype=dtype) + self.norm_q = ops.RMSNorm(dim, eps=eps, elementwise_affine=True, device=device, dtype=dtype) if qk_norm else nn.Identity() + self.norm_k = ops.RMSNorm(dim, eps=eps, elementwise_affine=True, device=device, dtype=dtype) if qk_norm else nn.Identity() + + def forward(self, x, freqs, kv_cache=None, transformer_options={}): + b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim + + q = apply_rope1(self.norm_q(self.q(x)).view(b, s, n, d), freqs) + k = apply_rope1(self.norm_k(self.k(x)).view(b, s, n, d), freqs) + v = self.v(x).view(b, s, n, d) + + if kv_cache is None: + x = optimized_attention( + q.view(b, s, n * d), + k.view(b, s, n * d), + v.view(b, s, n * d), + heads=self.num_heads, + transformer_options=transformer_options, + ) + else: + end = kv_cache["end"] + new_end = end + s + + # Roped K and plain V go into cache + kv_cache["k"][:, end:new_end] = k + kv_cache["v"][:, end:new_end] = v + kv_cache["end"] = new_end + + x = optimized_attention( + q.view(b, s, n * d), + kv_cache["k"][:, :new_end].view(b, new_end, n * d), + kv_cache["v"][:, :new_end].view(b, new_end, n * d), + heads=self.num_heads, + transformer_options=transformer_options, + ) + + x = self.o(x) + return x + + +class CausalWanAttentionBlock(WanAttentionBlock): + """Transformer block with KV-cached self-attention and cross-attention caching.""" + + def __init__(self, cross_attn_type, dim, ffn_dim, num_heads, + window_size=(-1, -1), qk_norm=True, cross_attn_norm=False, + eps=1e-6, operation_settings={}): + super().__init__(cross_attn_type, dim, ffn_dim, num_heads, + window_size, qk_norm, cross_attn_norm, eps, + operation_settings=operation_settings) + self.self_attn = CausalWanSelfAttention( + dim, num_heads, window_size, qk_norm, eps, + operation_settings=operation_settings) + + def forward(self, x, e, freqs, context, context_img_len=257, + kv_cache=None, crossattn_cache=None, transformer_options={}): + if e.ndim < 4: + e = (comfy.model_management.cast_to(self.modulation, dtype=x.dtype, device=x.device) + e).chunk(6, dim=1) + else: + e = (comfy.model_management.cast_to(self.modulation, dtype=x.dtype, device=x.device).unsqueeze(0) + e).unbind(2) + + # Self-attention with optional KV cache + x = x.contiguous() + y = self.self_attn( + torch.addcmul(repeat_e(e[0], x), self.norm1(x), 1 + repeat_e(e[1], x)), + freqs, kv_cache=kv_cache, transformer_options=transformer_options) + x = torch.addcmul(x, y, repeat_e(e[2], x)) + del y + + # Cross-attention with optional caching + if crossattn_cache is not None and crossattn_cache.get("is_init"): + q = self.cross_attn.norm_q(self.cross_attn.q(self.norm3(x))) + x_ca = optimized_attention( + q, crossattn_cache["k"], crossattn_cache["v"], + heads=self.num_heads, transformer_options=transformer_options) + x = x + self.cross_attn.o(x_ca) + else: + x = x + self.cross_attn(self.norm3(x), context, context_img_len=context_img_len, transformer_options=transformer_options) + if crossattn_cache is not None: + crossattn_cache["k"] = self.cross_attn.norm_k(self.cross_attn.k(context)) + crossattn_cache["v"] = self.cross_attn.v(context) + crossattn_cache["is_init"] = True + + # FFN + y = self.ffn(torch.addcmul(repeat_e(e[3], x), self.norm2(x), 1 + repeat_e(e[4], x))) + x = torch.addcmul(x, y, repeat_e(e[5], x)) + return x + + +class CausalWanModel(WanModel): + """ + Wan 2.1 diffusion backbone with causal KV-cache support. + + Same weight structure as WanModel -- loads identical state dicts. + Adds forward_block() for frame-by-frame autoregressive inference. + """ + + def __init__(self, + model_type='t2v', + patch_size=(1, 2, 2), + text_len=512, + in_dim=16, + dim=2048, + ffn_dim=8192, + freq_dim=256, + text_dim=4096, + out_dim=16, + num_heads=16, + num_layers=32, + window_size=(-1, -1), + qk_norm=True, + cross_attn_norm=True, + eps=1e-6, + image_model=None, + device=None, + dtype=None, + operations=None): + super().__init__( + model_type=model_type, patch_size=patch_size, text_len=text_len, + in_dim=in_dim, dim=dim, ffn_dim=ffn_dim, freq_dim=freq_dim, + text_dim=text_dim, out_dim=out_dim, num_heads=num_heads, + num_layers=num_layers, window_size=window_size, qk_norm=qk_norm, + cross_attn_norm=cross_attn_norm, eps=eps, image_model=image_model, + wan_attn_block_class=CausalWanAttentionBlock, + device=device, dtype=dtype, operations=operations) + + def forward_block(self, x, timestep, context, start_frame, + kv_caches, crossattn_caches, clip_fea=None): + """ + Forward one temporal block for autoregressive inference. + + Args: + x: [B, C, block_frames, H, W] input latent for the current block + timestep: [B, block_frames] per-frame timesteps + context: [B, L, text_dim] raw text embeddings (pre-text_embedding) + start_frame: temporal frame index for RoPE offset + kv_caches: list of per-layer KV cache dicts + crossattn_caches: list of per-layer cross-attention cache dicts + clip_fea: optional CLIP features for I2V + + Returns: + flow_pred: [B, C_out, block_frames, H, W] flow prediction + """ + x = comfy.ldm.common_dit.pad_to_patch_size(x, self.patch_size) + bs, c, t, h, w = x.shape + + x = self.patch_embedding(x.float()).to(x.dtype) + grid_sizes = x.shape[2:] + x = x.flatten(2).transpose(1, 2) + + # Per-frame time embedding + e = self.time_embedding( + sinusoidal_embedding_1d(self.freq_dim, timestep.flatten()).to(dtype=x.dtype)) + e = e.reshape(timestep.shape[0], -1, e.shape[-1]) + e0 = self.time_projection(e).unflatten(2, (6, self.dim)) + + # Text embedding (reuses crossattn_cache after first block) + context = self.text_embedding(context) + + context_img_len = None + if clip_fea is not None and self.img_emb is not None: + context_clip = self.img_emb(clip_fea) + context = torch.concat([context_clip, context], dim=1) + context_img_len = clip_fea.shape[-2] + + # RoPE for current block's temporal position + freqs = self.rope_encode(t, h, w, t_start=start_frame, device=x.device, dtype=x.dtype) + + # Transformer blocks + for i, block in enumerate(self.blocks): + x = block(x, e=e0, freqs=freqs, context=context, + context_img_len=context_img_len, + kv_cache=kv_caches[i], + crossattn_cache=crossattn_caches[i]) + + # Head + x = self.head(x, e) + + # Unpatchify + x = self.unpatchify(x, grid_sizes) + return x[:, :, :t, :h, :w] + + def init_kv_caches(self, batch_size, max_seq_len, device, dtype): + """Create fresh KV caches for all layers.""" + caches = [] + for _ in range(self.num_layers): + caches.append({ + "k": torch.zeros(batch_size, max_seq_len, self.num_heads, self.head_dim, device=device, dtype=dtype), + "v": torch.zeros(batch_size, max_seq_len, self.num_heads, self.head_dim, device=device, dtype=dtype), + "end": 0, + }) + return caches + + def init_crossattn_caches(self, batch_size, device, dtype): + """Create fresh cross-attention caches for all layers.""" + caches = [] + for _ in range(self.num_layers): + caches.append({"is_init": False}) + return caches + + def reset_kv_caches(self, kv_caches): + """Reset KV caches to empty (reuse allocated memory).""" + for cache in kv_caches: + cache["end"] = 0 + + def reset_crossattn_caches(self, crossattn_caches): + """Reset cross-attention caches.""" + for cache in crossattn_caches: + cache["is_init"] = False + + @property + def head_dim(self): + return self.dim // self.num_heads + + def forward(self, x, timestep, context, clip_fea=None, time_dim_concat=None, transformer_options={}, **kwargs): + ar_state = transformer_options.get("ar_state") + if ar_state is not None: + bs = x.shape[0] + block_frames = x.shape[2] + t_per_frame = timestep.unsqueeze(1).expand(bs, block_frames) + return self.forward_block( + x=x, timestep=t_per_frame, context=context, + start_frame=ar_state["start_frame"], + kv_caches=ar_state["kv_caches"], + crossattn_caches=ar_state["crossattn_caches"], + clip_fea=clip_fea, + ) + + return super().forward(x, timestep, context, clip_fea=clip_fea, + time_dim_concat=time_dim_concat, + transformer_options=transformer_options, **kwargs) diff --git a/comfy/model_base.py b/comfy/model_base.py index b61a2aa09..57a1e44d2 100644 --- a/comfy/model_base.py +++ b/comfy/model_base.py @@ -42,6 +42,7 @@ import comfy.ldm.cosmos.predict2 import comfy.ldm.lumina.model import comfy.ldm.wan.model import comfy.ldm.wan.model_animate +import comfy.ldm.wan.ar_model import comfy.ldm.hunyuan3d.model import comfy.ldm.hidream.model import comfy.ldm.chroma.model @@ -1365,6 +1366,13 @@ class WAN21(BaseModel): return out +class WAN21_CausalAR(WAN21): + def __init__(self, model_config, model_type=ModelType.FLOW, device=None): + super(WAN21, self).__init__(model_config, model_type, device=device, + unet_model=comfy.ldm.wan.ar_model.CausalWanModel) + self.image_to_video = False + + class WAN21_Vace(WAN21): def __init__(self, model_config, model_type=ModelType.FLOW, image_to_video=False, device=None): super(WAN21, self).__init__(model_config, model_type, device=device, unet_model=comfy.ldm.wan.model.VaceWanModel) diff --git a/comfy/supported_models.py b/comfy/supported_models.py index e6c17fb98..dff40461f 100644 --- a/comfy/supported_models.py +++ b/comfy/supported_models.py @@ -1167,6 +1167,25 @@ class WAN21_T2V(supported_models_base.BASE): t5_detect = comfy.text_encoders.sd3_clip.t5_xxl_detect(state_dict, "{}umt5xxl.transformer.".format(pref)) return supported_models_base.ClipTarget(comfy.text_encoders.wan.WanT5Tokenizer, comfy.text_encoders.wan.te(**t5_detect)) +class WAN21_CausalAR_T2V(WAN21_T2V): + unet_config = { + "image_model": "wan2.1", + "model_type": "t2v", + "causal_ar": True, + } + + sampling_settings = { + "shift": 5.0, + } + + def __init__(self, unet_config): + super().__init__(unet_config) + self.unet_config.pop("causal_ar", None) + + def get_model(self, state_dict, prefix="", device=None): + return model_base.WAN21_CausalAR(self, device=device) + + class WAN21_I2V(WAN21_T2V): unet_config = { "image_model": "wan2.1", @@ -1929,6 +1948,7 @@ models = [ ZImage, Lumina2, WAN22_T2V, + WAN21_CausalAR_T2V, WAN21_T2V, WAN21_I2V, WAN21_FunControl2V, diff --git a/comfy_extras/nodes_ar_video.py b/comfy_extras/nodes_ar_video.py new file mode 100644 index 000000000..09ee886fd --- /dev/null +++ b/comfy_extras/nodes_ar_video.py @@ -0,0 +1,84 @@ +""" +ComfyUI nodes for autoregressive video generation (Causal Forcing, Self-Forcing, etc.). + - EmptyARVideoLatent: create 5D [B, C, T, H, W] video latent tensors + - SamplerARVideo: SAMPLER for the block-by-block autoregressive denoising loop +""" + +import torch +from typing_extensions import override + +import comfy.model_management +import comfy.samplers +from comfy_api.latest import ComfyExtension, io + + +class EmptyARVideoLatent(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="EmptyARVideoLatent", + category="latent/video", + inputs=[ + io.Int.Input("width", default=832, min=16, max=8192, step=16), + io.Int.Input("height", default=480, min=16, max=8192, step=16), + io.Int.Input("length", default=81, min=1, max=1024, step=4), + io.Int.Input("batch_size", default=1, min=1, max=64), + ], + outputs=[ + io.Latent.Output(display_name="LATENT"), + ], + ) + + @classmethod + def execute(cls, width, height, length, batch_size) -> io.NodeOutput: + lat_t = ((length - 1) // 4) + 1 + latent = torch.zeros( + [batch_size, 16, lat_t, height // 8, width // 8], + device=comfy.model_management.intermediate_device(), + ) + return io.NodeOutput({"samples": latent}) + + +class SamplerARVideo(io.ComfyNode): + """Sampler for autoregressive video models (Causal Forcing, Self-Forcing). + + All AR-loop parameters are owned by this node so they live in the workflow. + Add new widgets here as the AR sampler grows new options. + """ + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="SamplerARVideo", + display_name="Sampler AR Video", + category="sampling/custom_sampling/samplers", + inputs=[ + io.Int.Input( + "num_frame_per_block", + default=1, min=1, max=64, + tooltip="Frames per autoregressive block. 1 = framewise, " + "3 = chunkwise. Must match the checkpoint's training mode.", + ), + ], + outputs=[io.Sampler.Output()], + ) + + @classmethod + def execute(cls, num_frame_per_block) -> io.NodeOutput: + extra_options = { + "num_frame_per_block": num_frame_per_block, + } + return io.NodeOutput(comfy.samplers.ksampler("ar_video", extra_options)) + + +class ARVideoExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ + EmptyARVideoLatent, + SamplerARVideo, + ] + + +async def comfy_entrypoint() -> ARVideoExtension: + return ARVideoExtension() diff --git a/nodes.py b/nodes.py index eebc2fa76..1e41b2ae0 100644 --- a/nodes.py +++ b/nodes.py @@ -2412,6 +2412,7 @@ async def init_builtin_extra_nodes(): "nodes_nop.py", "nodes_kandinsky5.py", "nodes_wanmove.py", + "nodes_ar_video.py", "nodes_image_compare.py", "nodes_zimage.py", "nodes_glsl.py", From 8d752113007178d6fbdf09ef01473c5233802cf2 Mon Sep 17 00:00:00 2001 From: Alvin Tang Date: Tue, 5 May 2026 20:29:11 +0800 Subject: [PATCH 13/56] fix: SplitImageToTileList and ImageMergeTileList to use tile_height for vertical stride minimum (#12882) --- comfy_extras/nodes_images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 68916ab75..1ac740d1d 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -716,7 +716,7 @@ class SplitImageToTileList(IO.ComfyNode): def get_grid_coords(width, height, tile_width, tile_height, overlap): coords = [] stride_x = round(max(tile_width * 0.25, tile_width - overlap)) - stride_y = round(max(tile_width * 0.25, tile_height - overlap)) + stride_y = round(max(tile_height * 0.25, tile_height - overlap)) y = 0 while y < height: From c55ff8524373940a404a130394fa7078ff64f9cd Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Tue, 5 May 2026 16:49:07 +0300 Subject: [PATCH 14/56] feat(api-nodes): add Luma UNI-1 models (#13614) Signed-off-by: bigcat88 Co-authored-by: Alexis Rolland --- comfy_api_nodes/apis/luma.py | 38 ++++- comfy_api_nodes/nodes_luma.py | 306 +++++++++++++++++++++++++++++++++- 2 files changed, 330 insertions(+), 14 deletions(-) diff --git a/comfy_api_nodes/apis/luma.py b/comfy_api_nodes/apis/luma.py index 632c4ab96..8c6db2022 100644 --- a/comfy_api_nodes/apis/luma.py +++ b/comfy_api_nodes/apis/luma.py @@ -1,15 +1,12 @@ from __future__ import annotations - -import torch - from enum import Enum from typing import Optional, Union +import torch from pydantic import BaseModel, Field, confloat - class LumaIO: LUMA_REF = "LUMA_REF" LUMA_CONCEPTS = "LUMA_CONCEPTS" @@ -183,13 +180,13 @@ class LumaAssets(BaseModel): class LumaImageRef(BaseModel): - '''Used for image gen''' + """Used for image gen""" url: str = Field(..., description='The URL of the image reference') weight: confloat(ge=0.0, le=1.0) = Field(..., description='The weight of the image reference') class LumaImageReference(BaseModel): - '''Used for video gen''' + """Used for video gen""" type: Optional[str] = Field('image', description='Input type, defaults to image') url: str = Field(..., description='The URL of the image') @@ -251,3 +248,32 @@ class LumaGeneration(BaseModel): assets: Optional[LumaAssets] = Field(None, description='The assets of the generation') model: str = Field(..., description='The model used for the generation') request: Union[LumaGenerationRequest, LumaImageGenerationRequest] = Field(..., description="The request used for the generation") + + +class Luma2ImageRef(BaseModel): + url: str | None = None + data: str | None = None + media_type: str | None = None + + +class Luma2GenerationRequest(BaseModel): + prompt: str = Field(..., min_length=1, max_length=6000) + model: str | None = None + type: str | None = None + aspect_ratio: str | None = None + style: str | None = None + output_format: str | None = None + web_search: bool | None = None + image_ref: list[Luma2ImageRef] | None = None + source: Luma2ImageRef | None = None + + +class Luma2Generation(BaseModel): + id: str | None = None + type: str | None = None + state: str | None = None + model: str | None = None + created_at: str | None = None + output: list[LumaImageReference] | None = None + failure_reason: str | None = None + failure_code: str | None = None diff --git a/comfy_api_nodes/nodes_luma.py b/comfy_api_nodes/nodes_luma.py index 9ed6cd299..d92a7c382 100644 --- a/comfy_api_nodes/nodes_luma.py +++ b/comfy_api_nodes/nodes_luma.py @@ -1,10 +1,11 @@ -from typing import Optional - import torch from typing_extensions import override -from comfy_api.latest import IO, ComfyExtension +from comfy_api.latest import IO, ComfyExtension, Input from comfy_api_nodes.apis.luma import ( + Luma2Generation, + Luma2GenerationRequest, + Luma2ImageRef, LumaAspectRatio, LumaCharacterRef, LumaConceptChain, @@ -30,6 +31,7 @@ from comfy_api_nodes.util import ( download_url_to_video_output, poll_op, sync_op, + upload_image_to_comfyapi, upload_images_to_comfyapi, validate_string, ) @@ -212,9 +214,9 @@ class LumaImageGenerationNode(IO.ComfyNode): aspect_ratio: str, seed, style_image_weight: float, - image_luma_ref: Optional[LumaReferenceChain] = None, - style_image: Optional[torch.Tensor] = None, - character_image: Optional[torch.Tensor] = None, + image_luma_ref: LumaReferenceChain | None = None, + style_image: torch.Tensor | None = None, + character_image: torch.Tensor | None = None, ) -> IO.NodeOutput: validate_string(prompt, strip_whitespace=True, min_length=3) # handle image_luma_ref @@ -434,7 +436,7 @@ class LumaTextToVideoGenerationNode(IO.ComfyNode): duration: str, loop: bool, seed, - luma_concepts: Optional[LumaConceptChain] = None, + luma_concepts: LumaConceptChain | None = None, ) -> IO.NodeOutput: validate_string(prompt, strip_whitespace=False, min_length=3) duration = duration if model != LumaVideoModel.ray_1_6 else None @@ -533,7 +535,6 @@ class LumaImageToVideoGenerationNode(IO.ComfyNode): ], is_api_node=True, price_badge=PRICE_BADGE_VIDEO, - ) @classmethod @@ -644,6 +645,293 @@ PRICE_BADGE_VIDEO = IO.PriceBadge( ) +def _luma2_uni1_common_inputs(max_image_refs: int) -> list: + return [ + IO.Combo.Input( + "style", + options=["auto", "manga"], + default="auto", + tooltip="Style preset. 'auto' picks based on the prompt; " + "'manga' applies a manga/anime aesthetic and requires a portrait " + "aspect ratio (2:3, 9:16, 1:2, 1:3).", + ), + IO.Boolean.Input( + "web_search", + default=False, + tooltip="Search the web for visual references before generating.", + ), + IO.Autogrow.Input( + "image_ref", + template=IO.Autogrow.TemplateNames( + IO.Image.Input("image"), + names=[f"image_{i}" for i in range(1, max_image_refs + 1)], + min=0, + ), + optional=True, + tooltip=f"Up to {max_image_refs} reference images for style/content guidance.", + ), + ] + + +async def _luma2_upload_image_refs( + cls: type[IO.ComfyNode], + refs: dict | None, + max_count: int, +) -> list[Luma2ImageRef] | None: + if not refs: + return None + out: list[Luma2ImageRef] = [] + for key in refs: + url = await upload_image_to_comfyapi(cls, refs[key]) + out.append(Luma2ImageRef(url=url)) + if len(out) > max_count: + raise ValueError(f"Maximum {max_count} reference images are allowed.") + return out or None + + +async def _luma2_submit_and_poll( + cls: type[IO.ComfyNode], + request: Luma2GenerationRequest, +) -> Input.Image: + initial = await sync_op( + cls, + ApiEndpoint(path="/proxy/luma_2/generations", method="POST"), + response_model=Luma2Generation, + data=request, + ) + if not initial.id: + raise RuntimeError("Luma 2 API did not return a generation id.") + final = await poll_op( + cls, + ApiEndpoint(path=f"/proxy/luma_2/generations/{initial.id}", method="GET"), + response_model=Luma2Generation, + status_extractor=lambda r: r.state, + progress_extractor=lambda r: None, + ) + if not final.output: + msg = final.failure_reason or "no output returned" + raise RuntimeError(f"Luma 2 generation failed: {msg}") + url = final.output[0].url + if not url: + raise RuntimeError("Luma 2 generation completed without an output URL.") + return await download_url_to_image_tensor(url) + + +class LumaImageNode(IO.ComfyNode): + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="LumaImageNode2", + display_name="Luma UNI-1 Image", + category="api node/image/Luma", + description="Generate images from text using the Luma UNI-1 model.", + inputs=[ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Text description of the desired image. 1–6000 characters.", + ), + IO.DynamicCombo.Input( + "model", + options=[ + IO.DynamicCombo.Option( + "uni-1", + [ + IO.Combo.Input( + "aspect_ratio", + options=[ + "auto", + "3:1", + "2:1", + "16:9", + "3:2", + "1:1", + "2:3", + "9:16", + "1:2", + "1:3", + ], + default="auto", + tooltip="Output image aspect ratio. 'auto' lets " + "the model pick based on the prompt.", + ), + *_luma2_uni1_common_inputs(max_image_refs=9), + ], + ), + IO.DynamicCombo.Option( + "uni-1-max", + [ + IO.Combo.Input( + "aspect_ratio", + options=[ + "auto", + "3:1", + "2:1", + "16:9", + "3:2", + "1:1", + "2:3", + "9:16", + "1:2", + "1:3", + ], + default="auto", + tooltip="Output image aspect ratio. 'auto' lets " + "the model pick based on the prompt.", + ), + *_luma2_uni1_common_inputs(max_image_refs=9), + ], + ), + ], + tooltip="Model to use for generation.", + ), + IO.Int.Input( + "seed", + default=0, + min=0, + max=2147483647, + control_after_generate=True, + tooltip="Seed controls whether the node should re-run; " + "results are non-deterministic regardless of seed.", + ), + ], + outputs=[IO.Image.Output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["model"], input_groups=["model.image_ref"]), + expr=""" + ( + $m := widgets.model; + $refs := $lookup(inputGroups, "model.image_ref"); + $base := $m = "uni-1-max" ? 0.1 : 0.0404; + {"type":"usd","usd": $round($base + 0.003 * $refs, 4)} + ) + """, + ), + ) + + @classmethod + async def execute( + cls, + prompt: str, + model: dict, + seed: int, + ) -> IO.NodeOutput: + validate_string(prompt, min_length=1, max_length=6000) + aspect_ratio = model["aspect_ratio"] + style = model["style"] + allowed_manga_ratios = {"2:3", "9:16", "1:2", "1:3"} + if style == "manga" and aspect_ratio != "auto" and aspect_ratio not in allowed_manga_ratios: + raise ValueError( + f"'manga' style requires a portrait aspect ratio " + f"({', '.join(sorted(allowed_manga_ratios))}) or 'auto'; got '{aspect_ratio}'." + ) + request = Luma2GenerationRequest( + prompt=prompt, + model=model["model"], + type="image", + aspect_ratio=aspect_ratio if aspect_ratio != "auto" else None, + style=style if style != "auto" else None, + output_format="png", + web_search=model["web_search"], + image_ref=await _luma2_upload_image_refs(cls, model.get("image_ref"), max_count=9), + ) + return IO.NodeOutput(await _luma2_submit_and_poll(cls, request)) + + +class LumaImageEditNode(IO.ComfyNode): + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="LumaImageEditNode2", + display_name="Luma UNI-1 Image Edit", + category="api node/image/Luma", + description="Edit an existing image with a text prompt using the Luma UNI-1 model.", + inputs=[ + IO.Image.Input( + "source", + tooltip="Source image to edit.", + ), + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Description of the desired edit. 1–6000 characters.", + ), + IO.DynamicCombo.Input( + "model", + options=[ + IO.DynamicCombo.Option( + "uni-1", + _luma2_uni1_common_inputs(max_image_refs=8), + ), + IO.DynamicCombo.Option( + "uni-1-max", + _luma2_uni1_common_inputs(max_image_refs=8), + ), + ], + tooltip="Model to use for editing.", + ), + IO.Int.Input( + "seed", + default=0, + min=0, + max=2147483647, + control_after_generate=True, + tooltip="Seed controls whether the node should re-run; " + "results are non-deterministic regardless of seed.", + ), + ], + outputs=[IO.Image.Output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["model"], input_groups=["model.image_ref"]), + expr=""" + ( + $m := widgets.model; + $refs := $lookup(inputGroups, "model.image_ref"); + $base := $m = "uni-1-max" ? 0.103 : 0.0434; + {"type":"usd","usd": $round($base + 0.003 * $refs, 4)} + ) + """, + ), + ) + + @classmethod + async def execute( + cls, + source: Input.Image, + prompt: str, + model: dict, + seed: int, + ) -> IO.NodeOutput: + validate_string(prompt, min_length=1, max_length=6000) + request = Luma2GenerationRequest( + prompt=prompt, + model=model["model"], + type="image_edit", + source=Luma2ImageRef(url=await upload_image_to_comfyapi(cls, source)), + style=model["style"] if model["style"] != "auto" else None, + output_format="png", + web_search=model["web_search"], + image_ref=await _luma2_upload_image_refs(cls, model.get("image_ref"), max_count=8), + ) + return IO.NodeOutput(await _luma2_submit_and_poll(cls, request)) + + class LumaExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -654,6 +942,8 @@ class LumaExtension(ComfyExtension): LumaImageToVideoGenerationNode, LumaReferenceNode, LumaConceptsNode, + LumaImageNode, + LumaImageEditNode, ] From 6917bce1281232a83c079a38540fca30d0fc279e Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Tue, 5 May 2026 16:53:19 +0300 Subject: [PATCH 15/56] [Partner Nodes] add Gpt 5.5 and 5.5-pro LLM models (#13673) * feat(api-nodes): add Gpt 5.5 and 5.5-pro LLM models Signed-off-by: bigcat88 --- comfy_api_nodes/apis/openai.py | 6 +++--- comfy_api_nodes/nodes_openai.py | 26 +++++++++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/comfy_api_nodes/apis/openai.py b/comfy_api_nodes/apis/openai.py index b85ef252b..bee75d639 100644 --- a/comfy_api_nodes/apis/openai.py +++ b/comfy_api_nodes/apis/openai.py @@ -56,14 +56,14 @@ class ModelResponseProperties(BaseModel): instructions: str | None = Field(None) max_output_tokens: int | None = Field(None) model: str | None = Field(None) - temperature: float | None = Field(1, description="Controls randomness in the response", ge=0.0, le=2.0) + temperature: float | None = Field(None, description="Controls randomness in the response", ge=0.0, le=2.0) top_p: float | None = Field( - 1, + None, description="Controls diversity of the response via nucleus sampling", ge=0.0, le=1.0, ) - truncation: str | None = Field("disabled", description="Allowed values: 'auto' or 'disabled'") + truncation: str | None = Field(None, description="Allowed values: 'auto' or 'disabled'") class ResponseProperties(BaseModel): diff --git a/comfy_api_nodes/nodes_openai.py b/comfy_api_nodes/nodes_openai.py index 21fe470ce..daed495da 100644 --- a/comfy_api_nodes/nodes_openai.py +++ b/comfy_api_nodes/nodes_openai.py @@ -39,16 +39,18 @@ STARTING_POINT_ID_PATTERN = r"" class SupportedOpenAIModel(str, Enum): - o4_mini = "o4-mini" - o1 = "o1" - o3 = "o3" - o1_pro = "o1-pro" - gpt_4_1 = "gpt-4.1" - gpt_4_1_mini = "gpt-4.1-mini" - gpt_4_1_nano = "gpt-4.1-nano" + gpt_5_5_pro = "gpt-5.5-pro" + gpt_5_5 = "gpt-5.5" gpt_5 = "gpt-5" gpt_5_mini = "gpt-5-mini" gpt_5_nano = "gpt-5-nano" + gpt_4_1 = "gpt-4.1" + gpt_4_1_mini = "gpt-4.1-mini" + gpt_4_1_nano = "gpt-4.1-nano" + o4_mini = "o4-mini" + o3 = "o3" + o1_pro = "o1-pro" + o1 = "o1" async def validate_and_cast_response(response, timeout: int = None) -> torch.Tensor: @@ -739,6 +741,16 @@ class OpenAIChatNode(IO.ComfyNode): "usd": [0.002, 0.008], "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } } + : $contains($m, "gpt-5.5-pro") ? { + "type": "list_usd", + "usd": [0.03, 0.18], + "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } + } + : $contains($m, "gpt-5.5") ? { + "type": "list_usd", + "usd": [0.005, 0.03], + "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } + } : $contains($m, "gpt-5-nano") ? { "type": "list_usd", "usd": [0.00005, 0.0004], From d794b62939ed82c88160d569854c41a42186bd9a Mon Sep 17 00:00:00 2001 From: "Daxiong (Lin)" Date: Tue, 5 May 2026 22:57:27 +0900 Subject: [PATCH 16/56] Update workflow templates to v0.9.69 (#13714) * chore: update workflow templates to v0.9.69 * Update comfyui-workflow-templates to version 0.9.70 * Downgrade comfyui-workflow-templates to 0.9.69 --------- Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 32826e25a..e9415f2fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ comfyui-frontend-package==1.42.15 -comfyui-workflow-templates==0.9.68 +comfyui-workflow-templates==0.9.69 comfyui-embedded-docs==0.4.4 torch torchsde From 639f631a0848f27497c6a29d2fb7d06c921c744d Mon Sep 17 00:00:00 2001 From: Alexis Rolland Date: Tue, 5 May 2026 22:31:24 +0800 Subject: [PATCH 17/56] chore: Update display names and categories for text nodes (CORE-155) (#13712) --- comfy_extras/nodes_primitive.py | 6 ++- comfy_extras/nodes_string.py | 66 ++++++++++++++++----------------- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/comfy_extras/nodes_primitive.py b/comfy_extras/nodes_primitive.py index 3c8f90b19..33373266b 100644 --- a/comfy_extras/nodes_primitive.py +++ b/comfy_extras/nodes_primitive.py @@ -9,7 +9,8 @@ class String(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="PrimitiveString", - display_name="String", + search_aliases=["text", "string", "text box", "prompt"], + display_name="Text String", category="utils/primitive", inputs=[ io.String.Input("value"), @@ -27,7 +28,8 @@ class StringMultiline(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="PrimitiveStringMultiline", - display_name="String (Multiline)", + search_aliases=["text", "string", "text multiline", "string multiline", "text box", "prompt"], + display_name="Text String (Multiline)", category="utils/primitive", essentials_category="Basics", inputs=[ diff --git a/comfy_extras/nodes_string.py b/comfy_extras/nodes_string.py index 604076c4e..925a40da8 100644 --- a/comfy_extras/nodes_string.py +++ b/comfy_extras/nodes_string.py @@ -10,9 +10,9 @@ class StringConcatenate(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="StringConcatenate", - display_name="Text Concatenate", - category="utils/string", - search_aliases=["Concatenate", "text concat", "join text", "merge text", "combine strings", "concat", "concatenate", "append text", "combine text", "string"], + search_aliases=["concatenate", "text concat", "join text", "merge text", "combine strings", "string concat", "append text", "combine text"], + display_name="Concatenate Text", + category="text", inputs=[ io.String.Input("string_a", multiline=True), io.String.Input("string_b", multiline=True), @@ -33,9 +33,9 @@ class StringSubstring(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="StringSubstring", - search_aliases=["Substring", "extract text", "text portion"], - display_name="Text Substring", - category="utils/string", + search_aliases=["substring", "extract text", "text portion"], + display_name="Substring", + category="text", inputs=[ io.String.Input("string", multiline=True), io.Int.Input("start"), @@ -58,7 +58,7 @@ class StringLength(io.ComfyNode): node_id="StringLength", search_aliases=["character count", "text size", "string length"], display_name="Text Length", - category="utils/string", + category="text", inputs=[ io.String.Input("string", multiline=True), ], @@ -77,9 +77,9 @@ class CaseConverter(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="CaseConverter", - search_aliases=["Case Converter", "text case", "uppercase", "lowercase", "capitalize"], - display_name="Text Case Converter", - category="utils/string", + search_aliases=["case converter", "text case", "uppercase", "lowercase", "capitalize"], + display_name="Convert Text Case", + category="text", inputs=[ io.String.Input("string", multiline=True), io.Combo.Input("mode", options=["UPPERCASE", "lowercase", "Capitalize", "Title Case"]), @@ -110,9 +110,9 @@ class StringTrim(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="StringTrim", - search_aliases=["Trim", "clean whitespace", "remove whitespace", "strip"], - display_name="Text Trim", - category="utils/string", + search_aliases=["trim", "clean whitespace", "remove whitespace", "remove spaces","strip"], + display_name="Trim Text", + category="text", inputs=[ io.String.Input("string", multiline=True), io.Combo.Input("mode", options=["Both", "Left", "Right"]), @@ -141,9 +141,9 @@ class StringReplace(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="StringReplace", - search_aliases=["Replace", "find and replace", "substitute", "swap text"], - display_name="Text Replace", - category="utils/string", + search_aliases=["replace", "find and replace", "substitute", "swap text"], + display_name="Replace Text", + category="text", inputs=[ io.String.Input("string", multiline=True), io.String.Input("find", multiline=True), @@ -164,9 +164,9 @@ class StringContains(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="StringContains", - search_aliases=["Contains", "text includes", "string includes"], - display_name="Text Contains", - category="utils/string", + search_aliases=["contains", "text includes", "string includes"], + display_name="Contains Text", + category="text", inputs=[ io.String.Input("string", multiline=True), io.String.Input("substring", multiline=True), @@ -192,9 +192,9 @@ class StringCompare(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="StringCompare", - search_aliases=["Compare", "text match", "string equals", "starts with", "ends with"], - display_name="Text Compare", - category="utils/string", + search_aliases=["compare", "text match", "string equals", "starts with", "ends with"], + display_name="Compare Text", + category="text", inputs=[ io.String.Input("string_a", multiline=True), io.String.Input("string_b", multiline=True), @@ -228,9 +228,9 @@ class RegexMatch(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="RegexMatch", - search_aliases=["Regex Match", "regex", "pattern match", "text contains", "string match"], - display_name="Text Match", - category="utils/string", + search_aliases=["regex match", "regex", "pattern match", "text contains", "string match"], + display_name="Match Text", + category="text", inputs=[ io.String.Input("string", multiline=True), io.String.Input("regex_pattern", multiline=True), @@ -269,9 +269,9 @@ class RegexExtract(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="RegexExtract", - search_aliases=["Regex Extract", "regex", "pattern extract", "text parser", "parse text"], - display_name="Text Extract Substring", - category="utils/string", + search_aliases=["regex extract", "regex", "pattern extract", "text parser", "parse text"], + display_name="Extract Text", + category="text", inputs=[ io.String.Input("string", multiline=True), io.String.Input("regex_pattern", multiline=True), @@ -344,9 +344,9 @@ class RegexReplace(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="RegexReplace", - search_aliases=["Regex Replace", "regex", "pattern replace", "regex replace", "substitution"], - display_name="Text Replace (Regex)", - category="utils/string", + search_aliases=["regex replace", "regex", "pattern replace", "substitution"], + display_name="Replace Text (Regex)", + category="text", description="Find and replace text using regex patterns.", inputs=[ io.String.Input("string", multiline=True), @@ -381,8 +381,8 @@ class JsonExtractString(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="JsonExtractString", - display_name="Extract String from JSON", - category="utils/string", + display_name="Extract Text from JSON", + category="text", search_aliases=["json", "extract json", "parse json", "json value", "read json"], inputs=[ io.String.Input("json_string", multiline=True), From ea6880b04b88629b9dd07774298bdffea6923f9b Mon Sep 17 00:00:00 2001 From: THE MACHINE Date: Wed, 6 May 2026 02:00:03 +0800 Subject: [PATCH 18/56] Fix Content-Disposition header missing 'attachment;' prefix (#13093) Add missing 'attachment;' directive to Content-Disposition headers in server.py to ensure browsers properly download files instead of attempting to display them inline. Fixes 4 instances in the file download endpoint. Co-authored-by: guill --- server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server.py b/server.py index 2f3b438bb..0e85635d3 100644 --- a/server.py +++ b/server.py @@ -560,7 +560,7 @@ class PromptServer(): buffer.seek(0) return web.Response(body=buffer.read(), content_type=f'image/{image_format}', - headers={"Content-Disposition": f"filename=\"{filename}\""}) + headers={"Content-Disposition": f"attachment; filename=\"{filename}\""}) if 'channel' not in request.rel_url.query: channel = 'rgba' @@ -580,7 +580,7 @@ class PromptServer(): buffer.seek(0) return web.Response(body=buffer.read(), content_type='image/png', - headers={"Content-Disposition": f"filename=\"{filename}\""}) + headers={"Content-Disposition": f"attachment; filename=\"{filename}\""}) elif channel == 'a': with Image.open(file) as img: @@ -597,7 +597,7 @@ class PromptServer(): alpha_buffer.seek(0) return web.Response(body=alpha_buffer.read(), content_type='image/png', - headers={"Content-Disposition": f"filename=\"{filename}\""}) + headers={"Content-Disposition": f"attachment; filename=\"{filename}\""}) else: # Use the content type from asset resolution if available, # otherwise guess from the filename. @@ -614,7 +614,7 @@ class PromptServer(): return web.FileResponse( file, headers={ - "Content-Disposition": f"filename=\"{filename}\"", + "Content-Disposition": f"attachment; filename=\"{filename}\"", "Content-Type": content_type } ) From 41d73ad18094ddf9c91e40f548a52d013d07e894 Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Tue, 5 May 2026 12:33:16 -0600 Subject: [PATCH 19/56] fix(audio): drop sample_rate key from LTXVEmptyLatentAudio (CORE-157) (#13716) --- comfy_extras/nodes_lt_audio.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/comfy_extras/nodes_lt_audio.py b/comfy_extras/nodes_lt_audio.py index 3ec635c75..2c1f63afb 100644 --- a/comfy_extras/nodes_lt_audio.py +++ b/comfy_extras/nodes_lt_audio.py @@ -147,7 +147,6 @@ class LTXVEmptyLatentAudio(io.ComfyNode): z_channels = audio_vae.latent_channels audio_freq = audio_vae.first_stage_model.latent_frequency_bins - sampling_rate = int(audio_vae.first_stage_model.sample_rate) num_audio_latents = audio_vae.first_stage_model.num_of_latents_from_frames(frames_number, frame_rate) @@ -159,7 +158,6 @@ class LTXVEmptyLatentAudio(io.ComfyNode): return io.NodeOutput( { "samples": audio_latents, - "sample_rate": sampling_rate, "type": "audio", } ) From 1ac60da2c9c8f83654204b2a1db13908cf7614f7 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Tue, 5 May 2026 13:21:36 -0700 Subject: [PATCH 20/56] Add Spectral lint CI gate for openapi.yaml (#13410) * Add Spectral lint CI gate for openapi.yaml Adds a blocking Spectral lint check that runs on PRs touching openapi.yaml or the ruleset itself. The ruleset mirrors the one used for other Comfy-Org service specs: spectral:oas plus conventions for snake_case properties, camelCase operationIds, and response/schema shape. Gate runs at --fail-severity=error, which the spec currently passes with zero errors (a small number of non-blocking warnings/hints remain for WebSocket 101 responses, the existing loose error schema, and two snake_case wire fields). * ci: set least-privilege contents:read permissions on openapi-lint workflow Per CodeRabbit review on #13410. The job only checks out the repo and runs Spectral, so contents:read is sufficient and avoids inheriting any permissive repo/org default token scope. --------- Co-authored-by: guill --- .github/workflows/openapi-lint.yml | 31 ++++++++++ .spectral.yaml | 91 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 .github/workflows/openapi-lint.yml create mode 100644 .spectral.yaml diff --git a/.github/workflows/openapi-lint.yml b/.github/workflows/openapi-lint.yml new file mode 100644 index 000000000..be949de2a --- /dev/null +++ b/.github/workflows/openapi-lint.yml @@ -0,0 +1,31 @@ +name: OpenAPI Lint + +on: + pull_request: + paths: + - 'openapi.yaml' + - '.spectral.yaml' + - '.github/workflows/openapi-lint.yml' + +permissions: + contents: read + +jobs: + spectral: + name: Run Spectral + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Spectral + run: npm install -g @stoplight/spectral-cli@6 + + - name: Lint openapi.yaml + run: spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity=error diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 000000000..4bb4a4a94 --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1,91 @@ +extends: + - spectral:oas + +# Severity levels: error, warn, info, hint, off +# Rules from the built-in "spectral:oas" ruleset are active by default. +# Below we tune severity and add custom rules for our conventions. +# +# This ruleset mirrors Comfy-Org/cloud/.spectral.yaml so specs across the +# organization are linted against a single consistent standard. + +rules: + # ----------------------------------------------------------------------- + # Built-in rule severity overrides + # ----------------------------------------------------------------------- + operation-operationId: error + operation-description: warn + operation-tag-defined: error + info-contact: off + info-description: warn + no-eval-in-markdown: error + no-$ref-siblings: error + + # ----------------------------------------------------------------------- + # Custom rules: naming conventions + # ----------------------------------------------------------------------- + + # Property names should be snake_case + property-name-snake-case: + description: Property names must be snake_case + severity: warn + given: "$.components.schemas.*.properties[*]~" + then: + function: pattern + functionOptions: + match: "^[a-z][a-z0-9]*(_[a-z0-9]+)*$" + + # Operation IDs should be camelCase + operation-id-camel-case: + description: Operation IDs must be camelCase + severity: warn + given: "$.paths.*.*.operationId" + then: + function: pattern + functionOptions: + match: "^[a-z][a-zA-Z0-9]*$" + + # ----------------------------------------------------------------------- + # Custom rules: response conventions + # ----------------------------------------------------------------------- + + # Error responses (4xx, 5xx) should use a consistent shape + error-response-schema: + description: Error responses should reference a standard error schema + severity: hint + given: "$.paths.*.*.responses[?(@property >= '400' && @property < '600')].content['application/json'].schema" + then: + field: "$ref" + function: truthy + + # All 2xx responses with JSON body should have a schema + response-schema-defined: + description: Success responses with JSON content should define a schema + severity: warn + given: "$.paths.*.*.responses[?(@property >= '200' && @property < '300')].content['application/json']" + then: + field: schema + function: truthy + + # ----------------------------------------------------------------------- + # Custom rules: best practices + # ----------------------------------------------------------------------- + + # Path parameters must have a description + path-param-description: + description: Path parameters should have a description + severity: warn + given: + - "$.paths.*.parameters[?(@.in == 'path')]" + - "$.paths.*.*.parameters[?(@.in == 'path')]" + then: + field: description + function: truthy + + # Schemas should have a description + schema-description: + description: Component schemas should have a description + severity: hint + given: "$.components.schemas.*" + then: + field: description + function: truthy From 431fadb520bbd2d18cbbd4067e06222301f1b4fe Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Tue, 5 May 2026 13:58:32 -0700 Subject: [PATCH 21/56] fix(api-io): serialize MultiCombo multi_select as object config (#13484) * fix(api-io): serialize MultiCombo multi_select as object config * fix: remove dead code and redundant top-level keys from MultiCombo serialization * fix: correct skip warning to mention comfy_entrypoint, remove nonexistent NODES_LIST * fix: validate MultiCombo list values against options individually * fix: gate multiselect validation on schema config, improve error message, add tests --------- Co-authored-by: Ni-zav Co-authored-by: guill --- comfy_api/latest/_io.py | 13 ++-- execution.py | 9 ++- nodes.py | 2 +- .../multicombo_serialization_test.py | 78 +++++++++++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 tests-unit/comfy_api_test/multicombo_serialization_test.py diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 4942ed46c..e50266bc5 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -395,7 +395,6 @@ class Combo(ComfyTypeIO): @comfytype(io_type="COMBO") class MultiCombo(ComfyTypeI): '''Multiselect Combo input (dropdown for selecting potentially more than one value).''' - # TODO: something is wrong with the serialization, frontend does not recognize it as multiselect Type = list[str] class Input(Combo.Input): def __init__(self, id: str, options: list[str], display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, @@ -408,12 +407,14 @@ class MultiCombo(ComfyTypeI): self.default: list[str] def as_dict(self): - to_return = super().as_dict() | prune_dict({ - "multi_select": self.multiselect, - "placeholder": self.placeholder, - "chip": self.chip, + # Frontend expects `multi_select` to be an object config (not a boolean). + # Keep top-level `multiselect` from Combo.Input for backwards compatibility. + return super().as_dict() | prune_dict({ + "multi_select": prune_dict({ + "placeholder": self.placeholder, + "chip": self.chip, + }), }) - return to_return @comfytype(io_type="IMAGE") class Image(ComfyTypeIO): diff --git a/execution.py b/execution.py index 654db8426..f37d0360d 100644 --- a/execution.py +++ b/execution.py @@ -1019,7 +1019,12 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None): combo_options = extra_info.get("options", []) else: combo_options = input_type - if val not in combo_options: + is_multiselect = extra_info.get("multiselect", False) + if is_multiselect and isinstance(val, list): + invalid_vals = [v for v in val if v not in combo_options] + else: + invalid_vals = [val] if val not in combo_options else [] + if invalid_vals: input_config = info list_info = "" @@ -1034,7 +1039,7 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None): error = { "type": "value_not_in_list", "message": "Value not in list", - "details": f"{x}: '{val}' not in {list_info}", + "details": f"{x}: {', '.join(repr(v) for v in invalid_vals)} not in {list_info}", "extra_info": { "input_name": x, "input_config": input_config, diff --git a/nodes.py b/nodes.py index 1e41b2ae0..cf61d9df0 100644 --- a/nodes.py +++ b/nodes.py @@ -2262,7 +2262,7 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom logging.warning(f"Error while calling comfy_entrypoint in {module_path}: {e}") return False else: - logging.warning(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS or NODES_LIST (need one).") + logging.warning(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS or comfy_entrypoint (need one).") return False except Exception as e: logging.warning(traceback.format_exc()) diff --git a/tests-unit/comfy_api_test/multicombo_serialization_test.py b/tests-unit/comfy_api_test/multicombo_serialization_test.py new file mode 100644 index 000000000..421c65a0d --- /dev/null +++ b/tests-unit/comfy_api_test/multicombo_serialization_test.py @@ -0,0 +1,78 @@ +from comfy_api.latest._io import Combo, MultiCombo + + +def test_multicombo_serializes_multi_select_as_object(): + multi_combo = MultiCombo.Input( + id="providers", + options=["a", "b", "c"], + default=["a"], + ) + + serialized = multi_combo.as_dict() + + assert serialized["multiselect"] is True + assert "multi_select" in serialized + assert serialized["multi_select"] == {} + + +def test_multicombo_serializes_multi_select_with_placeholder_and_chip(): + multi_combo = MultiCombo.Input( + id="providers", + options=["a", "b", "c"], + default=["a"], + placeholder="Select providers", + chip=True, + ) + + serialized = multi_combo.as_dict() + + assert serialized["multiselect"] is True + assert serialized["multi_select"] == { + "placeholder": "Select providers", + "chip": True, + } + + +def test_combo_does_not_serialize_multiselect(): + """Regular Combo should not have multiselect in its serialized output.""" + combo = Combo.Input( + id="choice", + options=["a", "b", "c"], + ) + + serialized = combo.as_dict() + + # Combo sets multiselect=False, but prune_dict keeps False (not None), + # so it should be present but False + assert serialized.get("multiselect") is False + assert "multi_select" not in serialized + + +def _validate_combo_values(val, combo_options, is_multiselect): + """Reproduce the validation logic from execution.py for testing.""" + if is_multiselect and isinstance(val, list): + return [v for v in val if v not in combo_options] + else: + return [val] if val not in combo_options else [] + + +def test_multicombo_validation_accepts_valid_list(): + options = ["a", "b", "c"] + assert _validate_combo_values(["a", "b"], options, True) == [] + + +def test_multicombo_validation_rejects_invalid_values(): + options = ["a", "b", "c"] + assert _validate_combo_values(["a", "x"], options, True) == ["x"] + + +def test_multicombo_validation_accepts_empty_list(): + options = ["a", "b", "c"] + assert _validate_combo_values([], options, True) == [] + + +def test_combo_validation_rejects_list_even_with_valid_items(): + """A regular Combo should not accept a list value.""" + options = ["a", "b", "c"] + invalid = _validate_combo_values(["a", "b"], options, False) + assert len(invalid) > 0 From 89014792c966b04bf18f7ba62aee5169f9094e84 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Tue, 5 May 2026 14:20:09 -0700 Subject: [PATCH 22/56] feat: add cloud-specific fields to OSS openapi.yaml as nullable (#13623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add cloud-specific fields to OSS openapi.yaml as nullable Add cross-runtime fields with x-runtime: [cloud] extension and [cloud-only] description prefix per the convention established in BE-613. All new fields are nullable and not in required arrays, so they are purely additive. /api/features response: - max_upload_size (integer, int64) - free_tier_credits (integer, int32) - posthog_api_host (string, uri) - max_concurrent_jobs (integer, int32) - workflow_templates_version (string) - workflow_templates_source (string, enum) PromptRequest schema: - workflow_id (string, uuid) - workflow_version_id (string, uuid) POST /api/assets: - id field (uuid) on multipart/form-data for idempotent creation - application/json alternate content-type for URL-based uploads POST /api/assets/from-hash: - mime_type (string) to preserve type without re-inspection PUT /api/assets/{id}: - mime_type (string) for overriding auto-detection GET /api/assets additional query parameters: - job_ids (string) — filter by associated job UUIDs - include_public (boolean) — include workspace-public assets - asset_hash (string) — filter by exact content hash Resolves: BE-613 Blocks: BE-364, BE-361, BE-363 Co-authored-by: Matt Miller * fix(openapi): address CodeRabbit feedback (BE-613) - max_upload_size is set in both runtimes via SERVER_FEATURE_FLAGS; drop the cloud-only / nullable tagging. - Require `url` on the application/json POST /api/assets body so the contract is enforceable by validators and codegen. --------- Co-authored-by: Matt Miller --- openapi.yaml | 122 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 111 insertions(+), 11 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 30f85b6ad..29b5f544b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -631,7 +631,7 @@ paths: operationId: getFeatures tags: [system] summary: Get enabled feature flags - description: Returns a dictionary of feature flag names to their enabled state. + description: Returns a dictionary of feature flag names to their enabled state. Cloud deployments may include additional typed fields alongside the boolean flags. responses: "200": description: Feature flags @@ -641,6 +641,43 @@ paths: type: object additionalProperties: type: boolean + properties: + max_upload_size: + type: integer + format: int64 + minimum: 0 + description: "Maximum file upload size in bytes." + free_tier_credits: + type: integer + format: int32 + minimum: 0 + nullable: true + x-runtime: [cloud] + description: "[cloud-only] Credits available to free-tier users. Local ComfyUI returns null." + posthog_api_host: + type: string + format: uri + nullable: true + x-runtime: [cloud] + description: "[cloud-only] PostHog analytics proxy URL for frontend telemetry. Local ComfyUI returns null." + max_concurrent_jobs: + type: integer + format: int32 + minimum: 0 + nullable: true + x-runtime: [cloud] + description: "[cloud-only] Maximum concurrent jobs the authenticated user can run. Local ComfyUI returns null." + workflow_templates_version: + type: string + nullable: true + x-runtime: [cloud] + description: "[cloud-only] Version identifier for the workflow templates bundle. Local ComfyUI returns null." + workflow_templates_source: + type: string + nullable: true + enum: [dynamic_config_override, workflow_templates_version_json] + x-runtime: [cloud] + description: "[cloud-only] How the templates version was resolved. Local ComfyUI returns null." # --------------------------------------------------------------------------- # Node / Object Info @@ -1497,6 +1534,24 @@ paths: type: string enum: [asc, desc] description: Sort direction + - name: job_ids + in: query + schema: + type: string + x-runtime: [cloud] + description: "[cloud-only] Comma-separated UUIDs to filter assets by associated job." + - name: include_public + in: query + schema: + type: boolean + x-runtime: [cloud] + description: "[cloud-only] Include workspace-public assets in addition to the caller's own." + - name: asset_hash + in: query + schema: + type: string + x-runtime: [cloud] + description: "[cloud-only] Filter by exact content hash." responses: "200": description: Asset list @@ -1542,6 +1597,49 @@ paths: type: string format: uuid description: ID of an existing asset to use as the preview image + id: + type: string + format: uuid + nullable: true + x-runtime: [cloud] + description: "[cloud-only] Client-supplied asset ID for idempotent creation. If an asset with this ID already exists, the existing asset is returned." + application/json: + schema: + type: object + x-runtime: [cloud] + description: "[cloud-only] URL-based asset upload. Caller supplies a URL instead of a file body; the server fetches the content." + required: + - url + properties: + url: + type: string + format: uri + description: "[cloud-only] URL of the file to import as an asset" + name: + type: string + description: Display name for the asset + tags: + type: string + description: Comma-separated tags + user_metadata: + type: string + description: JSON-encoded user metadata + hash: + type: string + description: "Blake3 hash of the file content (e.g. blake3:abc123...)" + mime_type: + type: string + description: MIME type of the file (overrides auto-detected type) + preview_id: + type: string + format: uuid + description: ID of an existing asset to use as the preview image + id: + type: string + format: uuid + nullable: true + x-runtime: [cloud] + description: "[cloud-only] Client-supplied asset ID for idempotent creation. If an asset with this ID already exists, the existing asset is returned." responses: "201": description: Asset created @@ -1580,6 +1678,11 @@ paths: user_metadata: type: object additionalProperties: true + mime_type: + type: string + nullable: true + x-runtime: [cloud] + description: "[cloud-only] MIME type of the content, so the type is preserved without re-inspecting content. Ignored by local ComfyUI." responses: "201": description: Asset created from hash @@ -1644,6 +1747,11 @@ paths: type: string format: uuid description: ID of the asset to use as the preview + mime_type: + type: string + nullable: true + x-runtime: [cloud] + description: "[cloud-only] MIME type override when auto-detection was wrong. Ignored by local ComfyUI." responses: "200": description: Asset updated @@ -2004,21 +2112,13 @@ components: format: uuid nullable: true x-runtime: [cloud] - description: | - UUID identifying a hosted-cloud workflow entity to associate with this - job. Local ComfyUI doesn't track workflow entities and returns `null` - (or omits the field). The `x-runtime: [cloud]` extension marks this - as populated only by the hosted-cloud runtime; absence of the tag - means a field is populated by all runtimes. + description: "[cloud-only] Cloud workflow entity ID for tracking and gallery association. Ignored by local ComfyUI." workflow_version_id: type: string format: uuid nullable: true x-runtime: [cloud] - description: | - UUID identifying a hosted-cloud workflow version to associate with - this job. Local ComfyUI returns `null` (or omits the field). See - `workflow_id` above for `x-runtime` semantics. + description: "[cloud-only] Cloud workflow version ID for pinning execution to a specific version. Ignored by local ComfyUI." PromptResponse: type: object From 1655f8089a23232a94b36129286942c33e740168 Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Tue, 5 May 2026 17:30:00 -0600 Subject: [PATCH 23/56] Add temporal_downscale_ratio to LatentFormat (#13702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ozbayb <17261091+ozbayb@users.noreply.github.com> Co-authored-by: Alexis Rolland Co-authored-by: Jukka Seppänen <40791699+kijai@users.noreply.github.com> Co-authored-by: Jedrzej Kosinski --- comfy/latent_formats.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/comfy/latent_formats.py b/comfy/latent_formats.py index 3dac5be18..60c0dfd7e 100644 --- a/comfy/latent_formats.py +++ b/comfy/latent_formats.py @@ -9,6 +9,7 @@ class LatentFormat: latent_rgb_factors_reshape = None taesd_decoder_name = None spacial_downscale_ratio = 8 + temporal_downscale_ratio = 1 def process_in(self, latent): return latent * self.scale_factor @@ -235,6 +236,7 @@ class Flux2(LatentFormat): class Mochi(LatentFormat): latent_channels = 12 latent_dimensions = 3 + temporal_downscale_ratio = 6 def __init__(self): self.scale_factor = 1.0 @@ -278,6 +280,7 @@ class LTXV(LatentFormat): latent_channels = 128 latent_dimensions = 3 spacial_downscale_ratio = 32 + temporal_downscale_ratio = 8 def __init__(self): self.latent_rgb_factors = [ @@ -421,6 +424,7 @@ class LTXAV(LTXV): class HunyuanVideo(LatentFormat): latent_channels = 16 latent_dimensions = 3 + temporal_downscale_ratio = 4 scale_factor = 0.476986 latent_rgb_factors = [ [-0.0395, -0.0331, 0.0445], @@ -447,6 +451,7 @@ class HunyuanVideo(LatentFormat): class Cosmos1CV8x8x8(LatentFormat): latent_channels = 16 latent_dimensions = 3 + temporal_downscale_ratio = 8 latent_rgb_factors = [ [ 0.1817, 0.2284, 0.2423], @@ -472,6 +477,7 @@ class Cosmos1CV8x8x8(LatentFormat): class Wan21(LatentFormat): latent_channels = 16 latent_dimensions = 3 + temporal_downscale_ratio = 4 latent_rgb_factors = [ [-0.1299, -0.1692, 0.2932], @@ -734,6 +740,7 @@ class HunyuanVideo15(LatentFormat): latent_channels = 32 latent_dimensions = 3 spacial_downscale_ratio = 16 + temporal_downscale_ratio = 4 scale_factor = 1.03682 taesd_decoder_name = "lighttaehy1_5" @@ -788,6 +795,7 @@ class ZImagePixelSpace(ChromaRadiance): class CogVideoX(LatentFormat): latent_channels = 16 latent_dimensions = 3 + temporal_downscale_ratio = 4 def __init__(self): self.scale_factor = 1.15258426 From e5369c0eec8b1b9c6d1b12a2e4167b46c7fd1c1e Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Tue, 5 May 2026 17:40:53 -0600 Subject: [PATCH 24/56] feat: Context windows - add causal_window_fix to improve blending of context windows (CORE-100) (#13563) * Context windows: add causal_window_fix toggle * Fix slice_cond to correctly handle causal anchor index for temporal offsets --- comfy/context_windows.py | 33 ++++++++++++++++++++++++--- comfy_extras/nodes_context_windows.py | 6 +++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/comfy/context_windows.py b/comfy/context_windows.py index cb44ee6e8..db57537a2 100644 --- a/comfy/context_windows.py +++ b/comfy/context_windows.py @@ -63,7 +63,11 @@ class IndexListContextWindow(ContextWindowABC): dim = self.dim if dim == 0 and full.shape[dim] == 1: return full - idx = tuple([slice(None)] * dim + [self.index_list]) + indices = self.index_list + anchor_idx = getattr(self, 'causal_anchor_index', None) + if anchor_idx is not None and anchor_idx >= 0: + indices = [anchor_idx] + list(indices) + idx = tuple([slice(None)] * dim + [indices]) window = full[idx] if retain_index_list: idx = tuple([slice(None)] * dim + [retain_index_list]) @@ -113,7 +117,14 @@ def slice_cond(cond_value, window: IndexListContextWindow, x_in: torch.Tensor, d # skip leading latent positions that have no corresponding conditioning (e.g. reference frames) if temporal_offset > 0: - indices = [i - temporal_offset for i in window.index_list[temporal_offset:]] + anchor_idx = getattr(window, 'causal_anchor_index', None) + if anchor_idx is not None and anchor_idx >= 0: + # anchor occupies one of the no-cond positions, so skip one fewer from window.index_list + skip_count = temporal_offset - 1 + else: + skip_count = temporal_offset + + indices = [i - temporal_offset for i in window.index_list[skip_count:]] indices = [i for i in indices if 0 <= i] else: indices = list(window.index_list) @@ -150,7 +161,8 @@ class ContextFuseMethod: ContextResults = collections.namedtuple("ContextResults", ['window_idx', 'sub_conds_out', 'sub_conds', 'window']) class IndexListContextHandler(ContextHandlerABC): def __init__(self, context_schedule: ContextSchedule, fuse_method: ContextFuseMethod, context_length: int=1, context_overlap: int=0, context_stride: int=1, - closed_loop: bool=False, dim:int=0, freenoise: bool=False, cond_retain_index_list: list[int]=[], split_conds_to_windows: bool=False): + closed_loop: bool=False, dim:int=0, freenoise: bool=False, cond_retain_index_list: list[int]=[], split_conds_to_windows: bool=False, + causal_window_fix: bool=True): self.context_schedule = context_schedule self.fuse_method = fuse_method self.context_length = context_length @@ -162,6 +174,7 @@ class IndexListContextHandler(ContextHandlerABC): self.freenoise = freenoise self.cond_retain_index_list = [int(x.strip()) for x in cond_retain_index_list.split(",")] if cond_retain_index_list else [] self.split_conds_to_windows = split_conds_to_windows + self.causal_window_fix = causal_window_fix self.callbacks = {} @@ -318,6 +331,14 @@ class IndexListContextHandler(ContextHandlerABC): # allow processing to end between context window executions for faster Cancel comfy.model_management.throw_exception_if_processing_interrupted() + # causal_window_fix: prepend a pre-window frame that will be stripped post-forward + anchor_applied = False + if self.causal_window_fix: + anchor_idx = window.index_list[0] - 1 + if 0 <= anchor_idx < x_in.size(self.dim): + window.causal_anchor_index = anchor_idx + anchor_applied = True + for callback in comfy.patcher_extension.get_all_callbacks(IndexListCallbacks.EVALUATE_CONTEXT_WINDOWS, self.callbacks): callback(self, model, x_in, conds, timestep, model_options, window_idx, window, model_options, device, first_device) @@ -332,6 +353,12 @@ class IndexListContextHandler(ContextHandlerABC): if device is not None: for i in range(len(sub_conds_out)): sub_conds_out[i] = sub_conds_out[i].to(x_in.device) + + # strip causal_window_fix anchor if applied + if anchor_applied: + for i in range(len(sub_conds_out)): + sub_conds_out[i] = sub_conds_out[i].narrow(self.dim, 1, sub_conds_out[i].shape[self.dim] - 1) + results.append(ContextResults(window_idx, sub_conds_out, sub_conds, window)) return results diff --git a/comfy_extras/nodes_context_windows.py b/comfy_extras/nodes_context_windows.py index 0e43f2e44..fefc56d26 100644 --- a/comfy_extras/nodes_context_windows.py +++ b/comfy_extras/nodes_context_windows.py @@ -29,6 +29,7 @@ class ContextWindowsManualNode(io.ComfyNode): io.Boolean.Input("freenoise", default=False, tooltip="Whether to apply FreeNoise noise shuffling, improves window blending."), io.String.Input("cond_retain_index_list", default="", tooltip="List of latent indices to retain in the conditioning tensors for each window, for example setting this to '0' will use the initial start image for each window."), io.Boolean.Input("split_conds_to_windows", default=False, tooltip="Whether to split multiple conditionings (created by ConditionCombine) to each window based on region index."), + io.Boolean.Input("causal_window_fix", default=True, tooltip="Whether to add a causal fix frame to non-0-indexed context windows."), ], outputs=[ io.Model.Output(tooltip="The model with context windows applied during sampling."), @@ -38,7 +39,7 @@ class ContextWindowsManualNode(io.ComfyNode): @classmethod def execute(cls, model: io.Model.Type, context_length: int, context_overlap: int, context_schedule: str, context_stride: int, closed_loop: bool, fuse_method: str, dim: int, freenoise: bool, - cond_retain_index_list: list[int]=[], split_conds_to_windows: bool=False) -> io.Model: + cond_retain_index_list: list[int]=[], split_conds_to_windows: bool=False, causal_window_fix: bool=True) -> io.Model: model = model.clone() model.model_options["context_handler"] = comfy.context_windows.IndexListContextHandler( context_schedule=comfy.context_windows.get_matching_context_schedule(context_schedule), @@ -50,7 +51,8 @@ class ContextWindowsManualNode(io.ComfyNode): dim=dim, freenoise=freenoise, cond_retain_index_list=cond_retain_index_list, - split_conds_to_windows=split_conds_to_windows + split_conds_to_windows=split_conds_to_windows, + causal_window_fix=causal_window_fix, ) # make memory usage calculation only take into account the context window latents comfy.context_windows.create_prepare_sampling_wrapper(model) From c168960a12213df8123a2f234f04a4cf55bbe30d Mon Sep 17 00:00:00 2001 From: comfyanonymous <121283862+comfyanonymous@users.noreply.github.com> Date: Tue, 5 May 2026 17:00:11 -0700 Subject: [PATCH 25/56] First step of supporting save filenames without trailing _ (#13722) get_save_image_path now properly supports filenames without trailing underscores. This will be the saving behavior when using a mix of save image nodes using the old and the new format. ComfyUI_00001_.png ComfyUI_00002.png ComfyUI_00003.png ComfyUI_00004_.png --- folder_paths.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/folder_paths.py b/folder_paths.py index 80f4b291a..039f72636 100644 --- a/folder_paths.py +++ b/folder_paths.py @@ -432,7 +432,9 @@ def get_save_image_path(filename_prefix: str, output_dir: str, image_width=0, im prefix_len = len(os.path.basename(filename_prefix)) prefix = filename[:prefix_len + 1] try: - digits = int(filename[prefix_len + 1:].split('_')[0]) + remainder = filename[prefix_len + 1:] + base_remainder = remainder.split('.')[0] + digits = int(base_remainder.split('_')[0]) except: digits = 0 return digits, prefix From 160b95f75c9cf60b04fbbf4ec0b8f35f474ffb2a Mon Sep 17 00:00:00 2001 From: iChrist Date: Wed, 6 May 2026 05:47:57 +0300 Subject: [PATCH 26/56] Update language options in nodes_ace.py (#12578) * Update language options in nodes_ace.py Modified it to include all 51 language options ace-step1.5 supports instead of the original 23 comfyui had. * re-arrange list by popularity changed order of the languages to be ordered by popularity en is default unknown is last * Update comfy_extras/nodes_ace.py --- comfy_extras/nodes_ace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_ace.py b/comfy_extras/nodes_ace.py index 1602add84..affcf3b71 100644 --- a/comfy_extras/nodes_ace.py +++ b/comfy_extras/nodes_ace.py @@ -42,7 +42,7 @@ class TextEncodeAceStepAudio15(IO.ComfyNode): IO.Int.Input("bpm", default=120, min=10, max=300), IO.Float.Input("duration", default=120.0, min=0.0, max=2000.0, step=0.1), IO.Combo.Input("timesignature", options=['2', '3', '4', '6']), - IO.Combo.Input("language", options=["en", "ja", "zh", "es", "de", "fr", "pt", "ru", "it", "nl", "pl", "tr", "vi", "cs", "fa", "id", "ko", "uk", "hu", "ar", "sv", "ro", "el"]), + IO.Combo.Input("language", options=['ar', 'az', 'bg', 'bn', 'ca', 'cs', 'da', 'de', 'el', 'en', 'es', 'fa', 'fi', 'fr', 'he', 'hi', 'hr', 'ht', 'hu', 'id', 'is', 'it', 'ja', 'ko', 'la', 'lt', 'ms', 'ne', 'nl', 'no', 'pa', 'pl', 'pt', 'ro', 'ru', 'sa', 'sk', 'sr', 'sv', 'sw', 'ta', 'te', 'th', 'tl', 'tr', 'uk', 'ur', 'vi', 'yue', 'zh', 'unknown'], default='en'), IO.Combo.Input("keyscale", options=[f"{root} {quality}" for quality in ["major", "minor"] for root in ["C", "C#", "Db", "D", "D#", "Eb", "E", "F", "F#", "Gb", "G", "G#", "Ab", "A", "A#", "Bb", "B"]]), IO.Boolean.Input("generate_audio_codes", default=True, tooltip="Enable the LLM that generates audio codes. This can be slow but will increase the quality of the generated audio. Turn this off if you are giving the model an audio reference.", advanced=True), IO.Float.Input("cfg_scale", default=2.0, min=0.0, max=100.0, step=0.1, advanced=True), From 2b63add0ad975d5f2f0cdc3d4fd8e71ae6553cbf Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Tue, 5 May 2026 19:56:09 -0700 Subject: [PATCH 27/56] fix: return millisecond timestamps from get_file_info() (#12996) --- app/user_manager.py | 4 ++-- tests-unit/prompt_server_test/user_manager_test.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/user_manager.py b/app/user_manager.py index e18afb71b..0517b3344 100644 --- a/app/user_manager.py +++ b/app/user_manager.py @@ -28,8 +28,8 @@ def get_file_info(path: str, relative_to: str) -> FileInfo: return { "path": os.path.relpath(path, relative_to).replace(os.sep, '/'), "size": os.path.getsize(path), - "modified": os.path.getmtime(path), - "created": os.path.getctime(path) + "modified": int(os.path.getmtime(path) * 1000), + "created": int(os.path.getctime(path) * 1000), } diff --git a/tests-unit/prompt_server_test/user_manager_test.py b/tests-unit/prompt_server_test/user_manager_test.py index b939d8e68..27118400f 100644 --- a/tests-unit/prompt_server_test/user_manager_test.py +++ b/tests-unit/prompt_server_test/user_manager_test.py @@ -69,7 +69,11 @@ async def test_listuserdata_full_info(aiohttp_client, app, tmp_path): assert len(result) == 1 assert result[0]["path"] == "file1.txt" assert "size" in result[0] - assert "modified" in result[0] + assert isinstance(result[0]["modified"], int) + assert isinstance(result[0]["created"], int) + # Verify millisecond magnitude (timestamps after year 2000 in ms are > 946684800000) + assert result[0]["modified"] > 946684800000 + assert result[0]["created"] > 946684800000 async def test_listuserdata_split_path(aiohttp_client, app, tmp_path): From 78b3096bf36ef32378a3d4299473b820211f1601 Mon Sep 17 00:00:00 2001 From: Talmaj Date: Wed, 6 May 2026 04:59:04 +0200 Subject: [PATCH 28/56] Void model - pass 1 & 2 (CORE-38) (#13403) --- comfy/latent_formats.py | 18 + comfy/sd.py | 5 + comfy/supported_models.py | 23 + comfy/text_encoders/cogvideo.py | 42 ++ comfy_extras/nodes_void.py | 483 +++++++++++++++++ comfy_extras/void_noise_warp.py | 494 ++++++++++++++++++ folder_paths.py | 2 + .../optical_flow/put_optical_flow_models_here | 0 nodes.py | 5 +- 9 files changed, 1070 insertions(+), 2 deletions(-) create mode 100644 comfy_extras/nodes_void.py create mode 100644 comfy_extras/void_noise_warp.py create mode 100644 models/optical_flow/put_optical_flow_models_here diff --git a/comfy/latent_formats.py b/comfy/latent_formats.py index 60c0dfd7e..91bebed3d 100644 --- a/comfy/latent_formats.py +++ b/comfy/latent_formats.py @@ -793,9 +793,27 @@ class ZImagePixelSpace(ChromaRadiance): pass class CogVideoX(LatentFormat): + """Latent format for CogVideoX-2b (THUDM/CogVideoX-2b). + + scale_factor matches the vae/config.json scaling_factor for the 2b variant. + The 5b-class checkpoints (CogVideoX-5b, CogVideoX-1.5-5B, CogVideoX-Fun-V1.5-*) + use a different value; see CogVideoX1_5 below. + """ latent_channels = 16 latent_dimensions = 3 temporal_downscale_ratio = 4 def __init__(self): self.scale_factor = 1.15258426 + + +class CogVideoX1_5(CogVideoX): + """Latent format for 5b-class CogVideoX checkpoints. + + Covers THUDM/CogVideoX-5b, THUDM/CogVideoX-1.5-5B, and the CogVideoX-Fun + V1.5-5b family (including VOID inpainting). All of these have + scaling_factor=0.7 in their vae/config.json. Auto-selected in + supported_models.CogVideoX_T2V based on transformer hidden dim. + """ + def __init__(self): + self.scale_factor = 0.7 diff --git a/comfy/sd.py b/comfy/sd.py index 9fce0e7d0..749bdd710 100644 --- a/comfy/sd.py +++ b/comfy/sd.py @@ -66,6 +66,7 @@ import comfy.text_encoders.longcat_image import comfy.text_encoders.qwen35 import comfy.text_encoders.ernie import comfy.text_encoders.gemma4 +import comfy.text_encoders.cogvideo import comfy.model_patcher import comfy.lora @@ -1224,6 +1225,7 @@ class CLIPType(Enum): NEWBIE = 24 FLUX2 = 25 LONGCAT_IMAGE = 26 + COGVIDEOX = 27 @@ -1428,6 +1430,9 @@ def load_text_encoder_state_dicts(state_dicts=[], embedding_directory=None, clip clip_target.clip = comfy.text_encoders.hidream.hidream_clip(**t5xxl_detect(clip_data), clip_l=False, clip_g=False, t5=True, llama=False, dtype_llama=None) clip_target.tokenizer = comfy.text_encoders.hidream.HiDreamTokenizer + elif clip_type == CLIPType.COGVIDEOX: + clip_target.clip = comfy.text_encoders.cogvideo.cogvideo_te(**t5xxl_detect(clip_data)) + clip_target.tokenizer = comfy.text_encoders.cogvideo.CogVideoXTokenizer else: #CLIPType.MOCHI clip_target.clip = comfy.text_encoders.genmo.mochi_te(**t5xxl_detect(clip_data)) clip_target.tokenizer = comfy.text_encoders.genmo.MochiT5Tokenizer diff --git a/comfy/supported_models.py b/comfy/supported_models.py index dff40461f..6a9613602 100644 --- a/comfy/supported_models.py +++ b/comfy/supported_models.py @@ -1872,6 +1872,14 @@ class CogVideoX_T2V(supported_models_base.BASE): vae_key_prefix = ["vae."] text_encoder_key_prefix = ["text_encoders."] + def __init__(self, unet_config): + # 2b-class (dim=1920, heads=30) uses scale_factor=1.15258426. + # 5b-class (dim=3072, heads=48) — incl. CogVideoX-5b, 1.5-5B, and + # Fun-V1.5 inpainting — uses scale_factor=0.7 per vae/config.json. + if unet_config.get("num_attention_heads", 0) >= 48: + self.latent_format = latent_formats.CogVideoX1_5 + super().__init__(unet_config) + def get_model(self, state_dict, prefix="", device=None): # CogVideoX 1.5 (patch_size_t=2) has different training base dimensions for RoPE if self.unet_config.get("patch_size_t") is not None: @@ -1898,6 +1906,20 @@ class CogVideoX_I2V(CogVideoX_T2V): out = model_base.CogVideoX(self, image_to_video=True, device=device) return out +class CogVideoX_Inpaint(CogVideoX_T2V): + unet_config = { + "image_model": "cogvideox", + "in_channels": 48, + } + + def get_model(self, state_dict, prefix="", device=None): + if self.unet_config.get("patch_size_t") is not None: + self.unet_config.setdefault("sample_height", 96) + self.unet_config.setdefault("sample_width", 170) + self.unet_config.setdefault("sample_frames", 81) + out = model_base.CogVideoX(self, image_to_video=True, device=device) + return out + models = [ LotusD, @@ -1978,6 +2000,7 @@ models = [ ErnieImage, SAM3, SAM31, + CogVideoX_Inpaint, CogVideoX_I2V, CogVideoX_T2V, SVD_img2vid, diff --git a/comfy/text_encoders/cogvideo.py b/comfy/text_encoders/cogvideo.py index f1e8e3f5d..b97310709 100644 --- a/comfy/text_encoders/cogvideo.py +++ b/comfy/text_encoders/cogvideo.py @@ -1,6 +1,48 @@ import comfy.text_encoders.sd3_clip +from comfy import sd1_clip class CogVideoXT5Tokenizer(comfy.text_encoders.sd3_clip.T5XXLTokenizer): + """Inner T5 tokenizer for CogVideoX. + + CogVideoX was trained with T5 embeddings padded to 226 tokens (not 77 like SD3). + Used both directly by supported_models.CogVideoX_T2V.clip_target (paired with + the raw T5XXLModel) and by the CogVideoXTokenizer outer wrapper below. + """ def __init__(self, embedding_directory=None, tokenizer_data={}): super().__init__(embedding_directory=embedding_directory, tokenizer_data=tokenizer_data, min_length=226) + + +class CogVideoXTokenizer(sd1_clip.SD1Tokenizer): + """Outer tokenizer wrapper for CLIPLoader (type="cogvideox").""" + def __init__(self, embedding_directory=None, tokenizer_data={}): + super().__init__(embedding_directory=embedding_directory, tokenizer_data=tokenizer_data, + clip_name="t5xxl", tokenizer=CogVideoXT5Tokenizer) + + +class CogVideoXT5XXL(sd1_clip.SD1ClipModel): + """Outer T5XXL model wrapper for CLIPLoader (type="cogvideox"). + + Wraps the raw T5XXL model in the SD1ClipModel interface so that CLIP.__init__ + (which reads self.dtypes) works correctly. The inner model is the standard + sd3_clip.T5XXLModel (no attention_mask change needed for CogVideoX). + """ + def __init__(self, device="cpu", dtype=None, model_options={}): + super().__init__(device=device, dtype=dtype, name="t5xxl", + clip_model=comfy.text_encoders.sd3_clip.T5XXLModel, + model_options=model_options) + + +def cogvideo_te(dtype_t5=None, t5_quantization_metadata=None): + """Factory that returns a CogVideoXT5XXL class configured with the detected + T5 dtype and optional quantization metadata, for use in load_text_encoder_state_dicts. + """ + class CogVideoXTEModel_(CogVideoXT5XXL): + def __init__(self, device="cpu", dtype=None, model_options={}): + if t5_quantization_metadata is not None: + model_options = model_options.copy() + model_options["t5xxl_quantization_metadata"] = t5_quantization_metadata + if dtype_t5 is not None: + dtype = dtype_t5 + super().__init__(device=device, dtype=dtype, model_options=model_options) + return CogVideoXTEModel_ diff --git a/comfy_extras/nodes_void.py b/comfy_extras/nodes_void.py new file mode 100644 index 000000000..e7a8f3757 --- /dev/null +++ b/comfy_extras/nodes_void.py @@ -0,0 +1,483 @@ +import logging + +import torch + +import comfy +import comfy.model_management +import comfy.model_patcher +import comfy.samplers +import comfy.utils +import folder_paths +import node_helpers +import nodes +from comfy.utils import model_trange as trange +from comfy_api.latest import ComfyExtension, io +from torchvision.models.optical_flow import raft_large +from typing_extensions import override + + +from comfy_extras.void_noise_warp import RaftOpticalFlow, get_noise_from_video + +OpticalFlow = io.Custom("OPTICAL_FLOW") + +TEMPORAL_COMPRESSION = 4 +PATCH_SIZE_T = 2 + + +def _valid_void_length(length: int) -> int: + """Round ``length`` down to a value that produces an even latent_t. + + VOID / CogVideoX-Fun-V1.5 uses patch_size_t=2, so the VAE-encoded latent + must have an even temporal dimension. If latent_t is odd, the transformer + pad_to_patch_size circular-wraps an extra latent frame onto the end; after + the post-transformer crop the last real latent frame has been influenced + by the wrapped phantom frame, producing visible jitter and "disappearing" + subjects near the end of the decoded video. Rounding down fixes this. + """ + latent_t = ((length - 1) // TEMPORAL_COMPRESSION) + 1 + if latent_t % PATCH_SIZE_T == 0: + return length + # Round latent_t down to the nearest multiple of PATCH_SIZE_T, then invert + # the ((length - 1) // TEMPORAL_COMPRESSION) + 1 formula. Floor at 1 frame + # so we never return a non-positive length. + target_latent_t = max(PATCH_SIZE_T, (latent_t // PATCH_SIZE_T) * PATCH_SIZE_T) + return (target_latent_t - 1) * TEMPORAL_COMPRESSION + 1 + + +class OpticalFlowLoader(io.ComfyNode): + """Load an optical flow model from ``models/optical_flow/``. + + Only torchvision's RAFT-large format is recognized today (the model used + by VOIDWarpedNoise). The checkpoint must be placed under + ``models/optical_flow/`` — ComfyUI never downloads optical-flow weights + at runtime. + """ + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="OpticalFlowLoader", + display_name="Load Optical Flow Model", + category="loaders", + inputs=[ + io.Combo.Input( + "model_name", + options=folder_paths.get_filename_list("optical_flow"), + tooltip=( + "Optical flow model to load. Files must be placed in the " + "'optical_flow' folder. Today only torchvision's " + "raft_large.pth is supported." + ), + ), + ], + outputs=[ + OpticalFlow.Output(), + ], + ) + + @classmethod + def execute(cls, model_name) -> io.NodeOutput: + + model_path = folder_paths.get_full_path_or_raise("optical_flow", model_name) + sd = comfy.utils.load_torch_file(model_path, safe_load=True) + + has_raft_keys = ( + any(k.startswith("feature_encoder.") for k in sd) + and any(k.startswith("context_encoder.") for k in sd) + and any(k.startswith("update_block.") for k in sd) + ) + if not has_raft_keys: + raise ValueError( + "Unrecognized optical flow model format: expected a torchvision " + "RAFT-large state dict with 'feature_encoder.', 'context_encoder.' " + "and 'update_block.' prefixes." + ) + + model = raft_large(weights=None, progress=False) + model.load_state_dict(sd) + model.eval().to(torch.float32) + + patcher = comfy.model_patcher.ModelPatcher( + model, + load_device=comfy.model_management.get_torch_device(), + offload_device=comfy.model_management.unet_offload_device(), + ) + return io.NodeOutput(patcher) + + +class VOIDQuadmaskPreprocess(io.ComfyNode): + """Preprocess a quadmask video for VOID inpainting. + + Quantizes mask values to four semantic levels, inverts, and normalizes: + 0 -> primary object to remove + 63 -> overlap of primary + affected + 127 -> affected region (interactions) + 255 -> background (keep) + + After inversion and normalization, the output mask has values in [0, 1] + with four discrete levels: 1.0 (remove), ~0.75, ~0.50, 0.0 (keep). + """ + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="VOIDQuadmaskPreprocess", + category="mask/video", + inputs=[ + io.Mask.Input("mask"), + io.Int.Input("dilate_width", default=0, min=0, max=50, step=1, + tooltip="Dilation radius for the primary mask region (0 = no dilation)"), + ], + outputs=[ + io.Mask.Output(display_name="quadmask"), + ], + ) + + @classmethod + def execute(cls, mask, dilate_width=0) -> io.NodeOutput: + m = mask.clone() + + if m.max() <= 1.0: + m = m * 255.0 + + if dilate_width > 0 and m.ndim >= 3: + binary = (m < 128).float() + kernel_size = dilate_width * 2 + 1 + if binary.ndim == 3: + binary = binary.unsqueeze(1) + dilated = torch.nn.functional.max_pool2d( + binary, kernel_size=kernel_size, stride=1, padding=dilate_width + ) + if dilated.ndim == 4: + dilated = dilated.squeeze(1) + m = torch.where(dilated > 0.5, torch.zeros_like(m), m) + + m = torch.where(m <= 31, torch.zeros_like(m), m) + m = torch.where((m > 31) & (m <= 95), torch.full_like(m, 63), m) + m = torch.where((m > 95) & (m <= 191), torch.full_like(m, 127), m) + m = torch.where(m > 191, torch.full_like(m, 255), m) + + m = (255.0 - m) / 255.0 + + return io.NodeOutput(m) + + +class VOIDInpaintConditioning(io.ComfyNode): + """Build VOID inpainting conditioning for CogVideoX. + + Encodes the processed quadmask and masked source video through the VAE, + producing a 32-channel concat conditioning (16ch mask + 16ch masked video) + that gets concatenated with the 16ch noise latent by the model. + """ + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="VOIDInpaintConditioning", + category="conditioning/video_models", + inputs=[ + io.Conditioning.Input("positive"), + io.Conditioning.Input("negative"), + io.Vae.Input("vae"), + io.Image.Input("video", tooltip="Source video frames [T, H, W, 3]"), + io.Mask.Input("quadmask", tooltip="Preprocessed quadmask from VOIDQuadmaskPreprocess [T, H, W]"), + io.Int.Input("width", default=672, min=16, max=nodes.MAX_RESOLUTION, step=8), + io.Int.Input("height", default=384, min=16, max=nodes.MAX_RESOLUTION, step=8), + io.Int.Input("length", default=45, min=1, max=nodes.MAX_RESOLUTION, step=1, + tooltip="Number of pixel frames to process. For CogVideoX-Fun-V1.5 " + "(patch_size_t=2), latent_t must be even — lengths that " + "produce odd latent_t are rounded down (e.g. 49 → 45)."), + io.Int.Input("batch_size", default=1, min=1, max=64), + ], + outputs=[ + io.Conditioning.Output(display_name="positive"), + io.Conditioning.Output(display_name="negative"), + io.Latent.Output(display_name="latent"), + ], + ) + + @classmethod + def execute(cls, positive, negative, vae, video, quadmask, + width, height, length, batch_size) -> io.NodeOutput: + + adjusted_length = _valid_void_length(length) + if adjusted_length != length: + logging.warning( + "VOIDInpaintConditioning: rounding length %d down to %d so that " + "latent_t is even (required by CogVideoX-Fun-V1.5 patch_size_t=2). " + "Using odd latent_t causes the last frame to be corrupted by " + "circular padding.", length, adjusted_length, + ) + length = adjusted_length + + latent_t = ((length - 1) // TEMPORAL_COMPRESSION) + 1 + latent_h = height // 8 + latent_w = width // 8 + + vid = video[:length] + vid = comfy.utils.common_upscale( + vid.movedim(-1, 1), width, height, "bilinear", "center" + ).movedim(1, -1) + + qm = quadmask[:length] + if qm.ndim == 3: + qm = qm.unsqueeze(-1) + qm = comfy.utils.common_upscale( + qm.movedim(-1, 1), width, height, "bilinear", "center" + ).movedim(1, -1) + if qm.ndim == 4 and qm.shape[-1] == 1: + qm = qm.squeeze(-1) + + mask_condition = qm + if mask_condition.ndim == 3: + mask_condition_3ch = mask_condition.unsqueeze(-1).expand(-1, -1, -1, 3) + else: + mask_condition_3ch = mask_condition + + inverted_mask_3ch = 1.0 - mask_condition_3ch + masked_video = vid[:, :, :, :3] * (1.0 - mask_condition_3ch) + + mask_latents = vae.encode(inverted_mask_3ch) + masked_video_latents = vae.encode(masked_video) + + def _match_temporal(lat, target_t): + if lat.shape[2] > target_t: + return lat[:, :, :target_t] + elif lat.shape[2] < target_t: + pad = target_t - lat.shape[2] + return torch.cat([lat, lat[:, :, -1:].repeat(1, 1, pad, 1, 1)], dim=2) + return lat + + mask_latents = _match_temporal(mask_latents, latent_t) + masked_video_latents = _match_temporal(masked_video_latents, latent_t) + + inpaint_latents = torch.cat([mask_latents, masked_video_latents], dim=1) + + # No explicit scaling needed here: the model's CogVideoX.concat_cond() + # applies process_latent_in (×latent_format.scale_factor) to each 16-ch + # block of the stored conditioning. For 5b-class checkpoints (incl. the + # VOID/CogVideoX-Fun-V1.5 inpainting model) that scale_factor is auto- + # selected as 0.7 in supported_models.CogVideoX_T2V, which matches the + # diffusers vae/config.json scaling_factor VOID was trained with. + + positive = node_helpers.conditioning_set_values( + positive, {"concat_latent_image": inpaint_latents} + ) + negative = node_helpers.conditioning_set_values( + negative, {"concat_latent_image": inpaint_latents} + ) + + noise_latent = torch.zeros( + [batch_size, 16, latent_t, latent_h, latent_w], + device=comfy.model_management.intermediate_device() + ) + + return io.NodeOutput(positive, negative, {"samples": noise_latent}) + + +class VOIDWarpedNoise(io.ComfyNode): + """Generate optical-flow warped noise for VOID Pass 2 refinement. + + Takes the Pass 1 output video and produces temporally-correlated noise + by warping Gaussian noise along optical flow vectors. This noise is used + as the initial latent for Pass 2, resulting in better temporal consistency. + """ + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="VOIDWarpedNoise", + category="latent/video", + inputs=[ + OpticalFlow.Input( + "optical_flow", + tooltip="Optical flow model from OpticalFlowLoader (RAFT-large).", + ), + io.Image.Input("video", tooltip="Pass 1 output video frames [T, H, W, 3]"), + io.Int.Input("width", default=672, min=16, max=nodes.MAX_RESOLUTION, step=8), + io.Int.Input("height", default=384, min=16, max=nodes.MAX_RESOLUTION, step=8), + io.Int.Input("length", default=45, min=1, max=nodes.MAX_RESOLUTION, step=1, + tooltip="Number of pixel frames. Rounded down to make latent_t " + "even (patch_size_t=2 requirement), e.g. 49 → 45."), + io.Int.Input("batch_size", default=1, min=1, max=64), + ], + outputs=[ + io.Latent.Output(display_name="warped_noise"), + ], + ) + + @classmethod + def execute(cls, optical_flow, video, width, height, length, batch_size) -> io.NodeOutput: + + adjusted_length = _valid_void_length(length) + if adjusted_length != length: + logging.warning( + "VOIDWarpedNoise: rounding length %d down to %d so that " + "latent_t is even (required by CogVideoX-Fun-V1.5 patch_size_t=2).", + length, adjusted_length, + ) + length = adjusted_length + + latent_t = ((length - 1) // TEMPORAL_COMPRESSION) + 1 + latent_h = height // 8 + latent_w = width // 8 + + # RAFT + noise warp is real compute, not an "intermediate" buffer, so + # we want the actual torch device (CUDA/MPS). The final latent is + # moved back to intermediate_device() before returning to match the + # rest of the ComfyUI pipeline. + device = comfy.model_management.get_torch_device() + + comfy.model_management.load_model_gpu(optical_flow) + raft = RaftOpticalFlow(optical_flow.model, device=device) + + vid = video[:length].to(device) + vid = comfy.utils.common_upscale( + vid.movedim(-1, 1), width, height, "bilinear", "center" + ).movedim(1, -1) + vid_uint8 = (vid.clamp(0, 1) * 255).to(torch.uint8) + + FRAME = 2**-1 + FLOW = 2**3 + LATENT_SCALE = 8 + + warped = get_noise_from_video( + vid_uint8, + raft, + noise_channels=16, + resize_frames=FRAME, + resize_flow=FLOW, + downscale_factor=round(FRAME * FLOW) * LATENT_SCALE, + device=device, + ) + + if warped.shape[0] != latent_t: + indices = torch.linspace(0, warped.shape[0] - 1, latent_t, + device=device).long() + warped = warped[indices] + + if warped.shape[1] != latent_h or warped.shape[2] != latent_w: + # (T, H, W, C) → (T, C, H, W) → bilinear resize → back + warped = warped.permute(0, 3, 1, 2) + warped = torch.nn.functional.interpolate( + warped, size=(latent_h, latent_w), + mode="bilinear", align_corners=False, + ) + warped = warped.permute(0, 2, 3, 1) + + # (T, H, W, C) → (B, C, T, H, W) + warped_tensor = warped.permute(3, 0, 1, 2).unsqueeze(0) + if batch_size > 1: + warped_tensor = warped_tensor.repeat(batch_size, 1, 1, 1, 1) + + warped_tensor = warped_tensor.to(comfy.model_management.intermediate_device()) + return io.NodeOutput({"samples": warped_tensor}) + + +class Noise_FromLatent: + """Wraps a pre-computed LATENT tensor as a NOISE source.""" + def __init__(self, latent_dict): + self.seed = 0 + self._samples = latent_dict["samples"] + + def generate_noise(self, input_latent): + return self._samples.clone().cpu() + + +class VOIDWarpedNoiseSource(io.ComfyNode): + """Convert a LATENT (e.g. from VOIDWarpedNoise) into a NOISE source + for use with SamplerCustomAdvanced.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="VOIDWarpedNoiseSource", + category="sampling/custom_sampling/noise", + inputs=[ + io.Latent.Input("warped_noise", + tooltip="Warped noise latent from VOIDWarpedNoise"), + ], + outputs=[io.Noise.Output()], + ) + + @classmethod + def execute(cls, warped_noise) -> io.NodeOutput: + return io.NodeOutput(Noise_FromLatent(warped_noise)) + + +class VOID_DDIM(comfy.samplers.Sampler): + """DDIM sampler for VOID inpainting models. + + VOID was trained with the diffusers CogVideoXDDIMScheduler which operates in + alpha-space (input std ≈ 1). The standard KSampler applies noise_scaling that + multiplies by sqrt(1+sigma^2) ≈ 4500x, which is incompatible with VOID's + training. This sampler skips noise_scaling and implements the DDIM update rule + directly using sigma-to-alpha conversion. + """ + + def sample(self, model_wrap, sigmas, extra_args, callback, noise, latent_image=None, denoise_mask=None, disable_pbar=False): + x = noise.to(torch.float32) + model_options = extra_args.get("model_options", {}) + seed = extra_args.get("seed", None) + s_in = x.new_ones([x.shape[0]]) + + for i in trange(len(sigmas) - 1, disable=disable_pbar): + sigma = sigmas[i] + sigma_next = sigmas[i + 1] + + denoised = model_wrap(x, sigma * s_in, model_options=model_options, seed=seed) + + if callback is not None: + callback(i, denoised, x, len(sigmas) - 1) + + if sigma_next == 0: + x = denoised + else: + alpha_t = 1.0 / (1.0 + sigma ** 2) + alpha_prev = 1.0 / (1.0 + sigma_next ** 2) + + pred_eps = (x - (alpha_t ** 0.5) * denoised) / (1.0 - alpha_t) ** 0.5 + x = (alpha_prev ** 0.5) * denoised + (1.0 - alpha_prev) ** 0.5 * pred_eps + + return x + + +class VOIDSampler(io.ComfyNode): + """VOID DDIM sampler for use with SamplerCustom / SamplerCustomAdvanced. + + Required for VOID inpainting models. Implements the same DDIM loop that VOID + was trained with (diffusers CogVideoXDDIMScheduler), without the noise_scaling + that the standard KSampler applies. Use with RandomNoise or VOIDWarpedNoiseSource. + """ + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="VOIDSampler", + category="sampling/custom_sampling/samplers", + inputs=[], + outputs=[io.Sampler.Output()], + ) + + @classmethod + def execute(cls) -> io.NodeOutput: + return io.NodeOutput(VOID_DDIM()) + + get_sampler = execute + + +class VOIDExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ + OpticalFlowLoader, + VOIDQuadmaskPreprocess, + VOIDInpaintConditioning, + VOIDWarpedNoise, + VOIDWarpedNoiseSource, + VOIDSampler, + ] + + +async def comfy_entrypoint() -> VOIDExtension: + return VOIDExtension() diff --git a/comfy_extras/void_noise_warp.py b/comfy_extras/void_noise_warp.py new file mode 100644 index 000000000..fcc9a5f8b --- /dev/null +++ b/comfy_extras/void_noise_warp.py @@ -0,0 +1,494 @@ +""" +Optical-flow-warped noise for VOID Pass 2 refinement. + +Adapted from RyannDaGreat/CommonSource (MIT License, Ryan Burgert): + https://github.com/RyannDaGreat/CommonSource + - noise_warp.py (NoiseWarper / warp_xyωc / regaussianize / get_noise_from_video) + - raft.py (RaftOpticalFlow) + +Only the code paths that ``comfy_extras/nodes_void.py::VOIDWarpedNoise`` actually +uses (torch THWC uint8 input, no background removal, no visualization, no disk +I/O, default warp/noise params) have been inlined. External ``rp`` utilities +have been replaced with equivalents from torch.nn.functional / einops. The +RAFT optical-flow model itself is loaded offline via ``OpticalFlowLoader`` in +``nodes_void.py`` and passed into ``get_noise_from_video`` by the caller; this +module never downloads weights at runtime. +""" + +import logging +from typing import Optional + +import torch +import torch.nn.functional as F +from einops import rearrange + +import comfy.model_management + + +# --------------------------------------------------------------------------- +# Low-level torch image helpers (drop-in replacements for rp.torch_* primitives) +# --------------------------------------------------------------------------- + +def _torch_resize_chw(image, size, interp, copy=True): + """Resize a CHW tensor. + + ``size`` is either a scalar factor or a (h, w) tuple. ``interp`` is one + of ``"bilinear"``, ``"nearest"``, ``"area"``. When ``copy`` is False and + the requested size matches the input, returns the input tensor as is + (faster but callers must not mutate the result). + """ + if image.ndim != 3: + raise ValueError( + f"_torch_resize_chw expects a 3D CHW tensor, got shape {tuple(image.shape)}" + ) + _, in_h, in_w = image.shape + if isinstance(size, (int, float)) and not isinstance(size, bool): + new_h = max(1, int(in_h * size)) + new_w = max(1, int(in_w * size)) + else: + new_h, new_w = size + + if (new_h, new_w) == (in_h, in_w): + return image.clone() if copy else image + + kwargs = {} + if interp in ("bilinear", "bicubic"): + kwargs["align_corners"] = False + out = F.interpolate(image[None], size=(new_h, new_w), mode=interp, **kwargs)[0] + return out + + +def _torch_remap_relative(image, dx, dy, interp="bilinear"): + """Relative remap of a CHW image via ``F.grid_sample``. + + Equivalent to ``rp.torch_remap_image(image, dx, dy, relative=True, interp=interp)`` + for ``interp`` in {"bilinear", "nearest"}. Out-of-bounds samples are 0. + """ + if image.ndim != 3: + raise ValueError( + f"_torch_remap_relative expects a 3D CHW tensor, got shape {tuple(image.shape)}" + ) + if dx.shape != dy.shape: + raise ValueError( + f"_torch_remap_relative: dx and dy must match, got {tuple(dx.shape)} vs {tuple(dy.shape)}" + ) + _, h, w = image.shape + + x_abs = dx + torch.arange(w, device=dx.device, dtype=dx.dtype) + y_abs = dy + torch.arange(h, device=dy.device, dtype=dy.dtype)[:, None] + + x_norm = (x_abs / (w - 1)) * 2 - 1 + y_norm = (y_abs / (h - 1)) * 2 - 1 + + grid = torch.stack([x_norm, y_norm], dim=-1)[None].to(image.dtype) + out = F.grid_sample( + image[None], grid, mode=interp, align_corners=True, padding_mode="zeros" + )[0] + return out + + +def _torch_scatter_add_relative(image, dx, dy): + """Scatter-add a CHW image using relative floor-rounded (dx, dy) offsets. + + Equivalent to ``rp.torch_scatter_add_image(image, dx, dy, relative=True, + interp='floor')``. Out-of-bounds targets are dropped. + """ + if image.ndim != 3: + raise ValueError( + f"_torch_scatter_add_relative expects a 3D CHW tensor, got shape {tuple(image.shape)}" + ) + in_c, in_h, in_w = image.shape + if dx.shape != (in_h, in_w) or dy.shape != (in_h, in_w): + raise ValueError( + f"_torch_scatter_add_relative: dx/dy must be ({in_h}, {in_w}), " + f"got dx={tuple(dx.shape)} dy={tuple(dy.shape)}" + ) + + x = dx.long() + torch.arange(in_w, device=dx.device, dtype=torch.long) + y = dy.long() + torch.arange(in_h, device=dy.device, dtype=torch.long)[:, None] + + valid = ((y >= 0) & (y < in_h) & (x >= 0) & (x < in_w)).reshape(-1) + indices = (y * in_w + x).reshape(-1)[valid] + + flat_image = rearrange(image, "c h w -> (h w) c")[valid] + out = torch.zeros((in_h * in_w, in_c), dtype=image.dtype, device=image.device) + out.index_add_(0, indices, flat_image) + return rearrange(out, "(h w) c -> c h w", h=in_h, w=in_w) + + +# --------------------------------------------------------------------------- +# Noise warping primitives (ported from noise_warp.py) +# --------------------------------------------------------------------------- + +def unique_pixels(image): + """Find unique pixel values in a CHW tensor. + + Returns ``(unique_colors [U, C], counts [U], index_matrix [H, W])`` where + ``index_matrix[i, j]`` is the index of the unique color at that pixel. + """ + _, h, w = image.shape + flat = rearrange(image, "c h w -> (h w) c") + unique_colors, inverse_indices, counts = torch.unique( + flat, dim=0, return_inverse=True, return_counts=True, sorted=False, + ) + index_matrix = rearrange(inverse_indices, "(h w) -> h w", h=h, w=w) + return unique_colors, counts, index_matrix + + +def sum_indexed_values(image, index_matrix): + """For each unique index, sum the CHW image values at its pixels.""" + _, h, w = image.shape + u = int(index_matrix.max().item()) + 1 + flat = rearrange(image, "c h w -> (h w) c") + out = torch.zeros((u, flat.shape[1]), dtype=flat.dtype, device=flat.device) + out.index_add_(0, index_matrix.view(-1), flat) + return out + + +def indexed_to_image(index_matrix, unique_colors): + """Build a CHW image from an index matrix and a (U, C) color table.""" + h, w = index_matrix.shape + flat = unique_colors[index_matrix.view(-1)] + return rearrange(flat, "(h w) c -> c h w", h=h, w=w) + + +def regaussianize(noise): + """Variance-preserving re-sampling of a CHW noise tensor. + + Wherever the noise contains groups of identical pixel values (e.g. after + a nearest-neighbor warp that duplicated source pixels), adds zero-mean + foreign noise within each group and scales by ``1/sqrt(count)`` so the + output is unit-variance gaussian again. + """ + _, hs, ws = noise.shape + _, counts, index_matrix = unique_pixels(noise[:1]) + + foreign_noise = torch.randn_like(noise) + summed = sum_indexed_values(foreign_noise, index_matrix) + meaned = indexed_to_image(index_matrix, summed / rearrange(counts, "u -> u 1")) + zeroed_foreign = foreign_noise - meaned + + counts_image = indexed_to_image(index_matrix, rearrange(counts, "u -> u 1")) + + output = noise / counts_image ** 0.5 + zeroed_foreign + return output, counts_image + + +def xy_meshgrid_like_image(image): + """Return a (2, H, W) tensor of (x, y) pixel coordinates matching ``image``.""" + _, h, w = image.shape + y, x = torch.meshgrid( + torch.arange(h, device=image.device, dtype=image.dtype), + torch.arange(w, device=image.device, dtype=image.dtype), + indexing="ij", + ) + return torch.stack([x, y]) + + +def noise_to_state(noise): + """Pack a (C, H, W) noise tensor into a state tensor (3+C, H, W) = [dx, dy, ω, noise].""" + zeros = torch.zeros_like(noise[:1]) + ones = torch.ones_like(noise[:1]) + return torch.cat([zeros, zeros, ones, noise]) + + +def state_to_noise(state): + """Unpack the noise channels from a state tensor.""" + return state[3:] + + +def warp_state(state, flow): + """Warp a noise-warper state tensor along the given optical flow. + + ``state`` has shape ``(3+c, h, w)`` (= dx, dy, ω, c noise channels). + ``flow`` has shape ``(2, h, w)`` (= dx, dy). + """ + if flow.device != state.device: + raise ValueError( + f"warp_state: flow and state must be on the same device, " + f"got flow={flow.device} state={state.device}" + ) + if state.ndim != 3: + raise ValueError( + f"warp_state: state must be 3D (3+C, H, W), got shape {tuple(state.shape)}" + ) + xyoc, h, w = state.shape + if flow.shape != (2, h, w): + raise ValueError( + f"warp_state: flow must have shape (2, {h}, {w}), got {tuple(flow.shape)}" + ) + device = state.device + + x_ch, y_ch = 0, 1 + xy = 2 # state[:xy] = [dx, dy] + xyw = 3 # state[:xyw] = [dx, dy, ω] + w_ch = 2 # state[w_ch] = ω + c = xyoc - xyw + oc = xyoc - xy + if c <= 0: + raise ValueError( + f"warp_state: state has no noise channels (expected 3+C with C>0, got {xyoc} channels)" + ) + if not (state[w_ch] > 0).all(): + raise ValueError("warp_state: all weights in state[2] must be > 0") + + grid = xy_meshgrid_like_image(state) + + init = torch.empty_like(state) + init[:xy] = 0 + init[w_ch] = 1 + init[-c:] = 0 + + # --- Expansion branch: nearest-neighbor remap with negated flow --- + pre_expand = torch.empty_like(state) + pre_expand[:xy] = _torch_remap_relative(state[:xy], -flow[0], -flow[1], "nearest") + pre_expand[-oc:] = _torch_remap_relative(state[-oc:], -flow[0], -flow[1], "nearest") + pre_expand[w_ch][pre_expand[w_ch] == 0] = 1 + + # --- Shrink branch: scatter-add state into new positions --- + pre_shrink = state.clone() + pre_shrink[:xy] += flow + + pos = (grid + pre_shrink[:xy]).round() + in_bounds = (pos[x_ch] >= 0) & (pos[x_ch] < w) & (pos[y_ch] >= 0) & (pos[y_ch] < h) + pre_shrink = torch.where(~in_bounds[None], init, pre_shrink) + + scat_xy = pre_shrink[:xy].round() + pre_shrink[:xy] -= scat_xy + pre_shrink[:xy] = 0 # xy_mode='none' in upstream + + def scat(tensor): + return _torch_scatter_add_relative(tensor, scat_xy[0], scat_xy[1]) + + # rp.torch_scatter_add_image on a bool tensor errors on modern torch; + # scatter-sum a float ones tensor and threshold to get the mask instead. + shrink_mask = scat(torch.ones(1, h, w, dtype=state.dtype, device=device)) > 0 + + # Drop expansion samples at positions that will be filled by shrink. + pre_expand = torch.where(shrink_mask, init, pre_expand) + + # Regaussianize both branches together so duplicated-source groups are + # counted globally, then split back apart. + concat = torch.cat([pre_shrink, pre_expand], dim=2) # along width + concat[-c:], counts_image = regaussianize(concat[-c:]) + concat[w_ch] = concat[w_ch] / counts_image[0] + concat[w_ch] = concat[w_ch].nan_to_num() + pre_shrink, expand = torch.chunk(concat, chunks=2, dim=2) + + shrink = torch.empty_like(pre_shrink) + shrink[w_ch] = scat(pre_shrink[w_ch][None])[0] + shrink[:xy] = scat(pre_shrink[:xy] * pre_shrink[w_ch][None]) / shrink[w_ch][None] + shrink[-c:] = scat(pre_shrink[-c:] * pre_shrink[w_ch][None]) / scat( + pre_shrink[w_ch][None] ** 2 + ).sqrt() + + output = torch.where(shrink_mask, shrink, expand) + output[w_ch] = output[w_ch] / output[w_ch].mean() + output[w_ch] += 1e-5 + output[w_ch] **= 0.9999 + return output + + +class NoiseWarper: + """Maintain a warpable noise state and emit gaussian noise per frame. + + Simplified from RyannDaGreat/CommonSource/noise_warp.py::NoiseWarper: + ``scale_factor``, ``post_noise_alpha``, ``progressive_noise_alpha``, and + ``warp_kwargs`` are all dropped since VOIDWarpedNoise always uses defaults. + """ + + def __init__(self, c, h, w, device, dtype=torch.float32): + if c <= 0 or h <= 0 or w <= 0: + raise ValueError( + f"NoiseWarper: c/h/w must all be positive, got c={c} h={h} w={w}" + ) + self.c = c + self.h = h + self.w = w + self.device = device + self.dtype = dtype + + noise = torch.randn(c, h, w, dtype=dtype, device=device) + self._state = noise_to_state(noise) + + @property + def noise(self): + # With scale_factor=1 the "downsample to respect weights" step is a + # size-preserving no-op; the weight-variance correction math still + # runs to stay faithful to upstream. + n = state_to_noise(self._state) + weights = self._state[2:3] + return n * weights / (weights ** 2).sqrt() + + def __call__(self, dx, dy): + if dx.shape != dy.shape: + raise ValueError( + f"NoiseWarper: dx and dy must match, got {tuple(dx.shape)} vs {tuple(dy.shape)}" + ) + flow = torch.stack([dx, dy]).to(self.device, self.dtype) + _, oflowh, ofloww = flow.shape + + flow = _torch_resize_chw(flow, (self.h, self.w), "bilinear", copy=True) + flowh, floww = flow.shape[-2:] + + # Upstream scales flow[0] by flowh/oflowh and flow[1] by floww/ofloww + # (channel-order appears swapped but harmless when H and W are scaled + # by the same factor, which is always the case for our callers). + flow[0] *= flowh / oflowh + flow[1] *= floww / ofloww + + self._state = warp_state(self._state, flow) + return self + + +# --------------------------------------------------------------------------- +# RAFT optical flow wrapper (ported from raft.py) +# --------------------------------------------------------------------------- + +class RaftOpticalFlow: + """RAFT-large wrapper around a pre-loaded torchvision model. + + ``model`` must be the ``torchvision.models.optical_flow.raft_large`` module + with its weights already populated; this class is load-agnostic so the + caller owns downloading/offload concerns (see ``OpticalFlowLoader`` in + ``nodes_void.py``). ``__call__`` returns a ``(2, H, W)`` flow. + """ + + def __init__(self, model, device=None): + if device is None: + device = comfy.model_management.get_torch_device() + device = torch.device(device) if not isinstance(device, torch.device) else device + + model = model.to(device) + model.eval() + self.device = device + self.model = model + + def _preprocess(self, image_chw): + image = image_chw.to(self.device, torch.float32) + _, h, w = image.shape + new_h = (h // 8) * 8 + new_w = (w // 8) * 8 + image = _torch_resize_chw(image, (new_h, new_w), "bilinear", copy=False) + image = image * 2 - 1 + return image[None] + + def __call__(self, from_image, to_image): + """``from_image``, ``to_image``: CHW float tensors in [0, 1].""" + if from_image.shape != to_image.shape: + raise ValueError( + f"RaftOpticalFlow: from_image and to_image must match, " + f"got {tuple(from_image.shape)} vs {tuple(to_image.shape)}" + ) + _, h, w = from_image.shape + with torch.no_grad(): + img1 = self._preprocess(from_image) + img2 = self._preprocess(to_image) + list_of_flows = self.model(img1, img2) + flow = list_of_flows[-1][0] # (2, new_h, new_w) + if flow.shape[-2:] != (h, w): + flow = _torch_resize_chw(flow, (h, w), "bilinear", copy=False) + return flow + + +# --------------------------------------------------------------------------- +# Narrow entry point used by VOIDWarpedNoise +# --------------------------------------------------------------------------- + +def get_noise_from_video( + video_frames: torch.Tensor, + raft: RaftOpticalFlow, + *, + noise_channels: int = 16, + resize_frames: float = 0.5, + resize_flow: int = 8, + downscale_factor: int = 32, + device: Optional[torch.device] = None, +) -> torch.Tensor: + """Produce optical-flow-warped gaussian noise from a video. + + Args: + video_frames: ``(T, H, W, 3)`` uint8 torch tensor. + raft: Pre-loaded RAFT optical-flow wrapper (see ``RaftOpticalFlow``). + noise_channels: Channels in the output noise. + resize_frames: Pre-RAFT frame scale factor. + resize_flow: Post-flow up-scale factor applied to the optical flow; + the internal noise state is allocated at + ``(resize_flow * resize_frames * H, resize_flow * resize_frames * W)``. + downscale_factor: Area-pool factor applied to the noise before return; + should evenly divide the internal noise resolution. + device: Target device. Defaults to ``comfy.model_management.get_torch_device()``. + + Returns: + ``(T, H', W', noise_channels)`` float32 noise tensor on ``device``. + """ + if not isinstance(resize_flow, int) or resize_flow < 1: + raise ValueError( + f"get_noise_from_video: resize_flow must be a positive int, got {resize_flow!r}" + ) + if video_frames.ndim != 4 or video_frames.shape[-1] != 3: + raise ValueError( + "get_noise_from_video: video_frames must have shape (T, H, W, 3), " + f"got {tuple(video_frames.shape)}" + ) + if video_frames.dtype != torch.uint8: + raise TypeError( + "get_noise_from_video: video_frames must be uint8 in [0, 255], " + f"got dtype {video_frames.dtype}" + ) + + if device is None: + device = comfy.model_management.get_torch_device() + device = torch.device(device) if not isinstance(device, torch.device) else device + + if device.type == "cpu": + logging.warning( + "VOIDWarpedNoise: running get_noise_from_video on CPU; this will be " + "slow (minutes for ~45 frames). Use CUDA for interactive use." + ) + + T = video_frames.shape[0] + frames = video_frames.to(device).permute(0, 3, 1, 2).to(torch.float32) / 255.0 + if resize_frames != 1.0: + new_h = max(1, int(frames.shape[2] * resize_frames)) + new_w = max(1, int(frames.shape[3] * resize_frames)) + frames = F.interpolate(frames, size=(new_h, new_w), mode="area") + + _, _, H, W = frames.shape + internal_h = resize_flow * H + internal_w = resize_flow * W + if internal_h % downscale_factor or internal_w % downscale_factor: + logging.warning( + "VOIDWarpedNoise: internal noise size %dx%d is not divisible by " + "downscale_factor %d; output noise may have artifacts.", + internal_h, internal_w, downscale_factor, + ) + + with torch.no_grad(): + warper = NoiseWarper( + c=noise_channels, h=internal_h, w=internal_w, device=device, + ) + down_h = warper.h // downscale_factor + down_w = warper.w // downscale_factor + output = torch.empty( + (T, down_h, down_w, noise_channels), dtype=torch.float32, device=device, + ) + + def downscale(noise_chw): + # Area-pool to 1/downscale_factor then multiply by downscale_factor + # to adjust std (sqrt of pool area == downscale_factor for a + # square pool). + down = _torch_resize_chw(noise_chw, 1.0 / downscale_factor, "area", copy=False) + return down * downscale_factor + + output[0] = downscale(warper.noise).permute(1, 2, 0) + + prev = frames[0] + for i in range(1, T): + curr = frames[i] + flow = raft(prev, curr).to(device) + warper(flow[0], flow[1]) + output[i] = downscale(warper.noise).permute(1, 2, 0) + prev = curr + + return output diff --git a/folder_paths.py b/folder_paths.py index 039f72636..98d3b1880 100644 --- a/folder_paths.py +++ b/folder_paths.py @@ -54,6 +54,8 @@ folder_names_and_paths["audio_encoders"] = ([os.path.join(models_dir, "audio_enc folder_names_and_paths["frame_interpolation"] = ([os.path.join(models_dir, "frame_interpolation")], supported_pt_extensions) +folder_names_and_paths["optical_flow"] = ([os.path.join(models_dir, "optical_flow")], supported_pt_extensions) + output_directory = os.path.join(base_path, "output") temp_directory = os.path.join(base_path, "temp") input_directory = os.path.join(base_path, "input") diff --git a/models/optical_flow/put_optical_flow_models_here b/models/optical_flow/put_optical_flow_models_here new file mode 100644 index 000000000..e69de29bb diff --git a/nodes.py b/nodes.py index cf61d9df0..ad0cbc675 100644 --- a/nodes.py +++ b/nodes.py @@ -958,7 +958,7 @@ class CLIPLoader: @classmethod def INPUT_TYPES(s): return {"required": { "clip_name": (folder_paths.get_filename_list("text_encoders"), ), - "type": (["stable_diffusion", "stable_cascade", "sd3", "stable_audio", "mochi", "ltxv", "pixart", "cosmos", "lumina2", "wan", "hidream", "chroma", "ace", "omnigen2", "qwen_image", "hunyuan_image", "flux2", "ovis", "longcat_image"], ), + "type": (["stable_diffusion", "stable_cascade", "sd3", "stable_audio", "mochi", "ltxv", "pixart", "cosmos", "lumina2", "wan", "hidream", "chroma", "ace", "omnigen2", "qwen_image", "hunyuan_image", "flux2", "ovis", "longcat_image", "cogvideox"], ), }, "optional": { "device": (["default", "cpu"], {"advanced": True}), @@ -968,7 +968,7 @@ class CLIPLoader: CATEGORY = "advanced/loaders" - DESCRIPTION = "[Recipes]\n\nstable_diffusion: clip-l\nstable_cascade: clip-g\nsd3: t5 xxl/ clip-g / clip-l\nstable_audio: t5 base\nmochi: t5 xxl\ncosmos: old t5 xxl\nlumina2: gemma 2 2B\nwan: umt5 xxl\n hidream: llama-3.1 (Recommend) or t5\nomnigen2: qwen vl 2.5 3B" + DESCRIPTION = "[Recipes]\n\nstable_diffusion: clip-l\nstable_cascade: clip-g\nsd3: t5 xxl/ clip-g / clip-l\nstable_audio: t5 base\nmochi: t5 xxl\ncogvideox: t5 xxl (226-token padding)\ncosmos: old t5 xxl\nlumina2: gemma 2 2B\nwan: umt5 xxl\n hidream: llama-3.1 (Recommend) or t5\nomnigen2: qwen vl 2.5 3B" def load_clip(self, clip_name, type="stable_diffusion", device="default"): clip_type = getattr(comfy.sd.CLIPType, type.upper(), comfy.sd.CLIPType.STABLE_DIFFUSION) @@ -2430,6 +2430,7 @@ async def init_builtin_extra_nodes(): "nodes_rtdetr.py", "nodes_frame_interpolation.py", "nodes_sam3.py", + "nodes_void.py", ] import_failed = [] From 9c34f5f36a3815af7d21d8b42b0a5776b7406685 Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Wed, 6 May 2026 14:22:48 +0900 Subject: [PATCH 29/56] Bump comfyui-frontend-package to 1.43.17 (#13723) Co-authored-by: github-actions[bot] Co-authored-by: Alexander Brown --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e9415f2fd..e7aa92c31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -comfyui-frontend-package==1.42.15 +comfyui-frontend-package==1.43.17 comfyui-workflow-templates==0.9.69 comfyui-embedded-docs==0.4.4 torch From 6bcd8b96ab4650db6e834dcbb54357ebf72edfe6 Mon Sep 17 00:00:00 2001 From: guill Date: Wed, 6 May 2026 10:08:35 -0700 Subject: [PATCH 30/56] Revert "Fix Content-Disposition header missing 'attachment;' prefix (#13093)" (#13733) This reverts commit ea6880b04b88629b9dd07774298bdffea6923f9b. --- server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server.py b/server.py index 0e85635d3..2f3b438bb 100644 --- a/server.py +++ b/server.py @@ -560,7 +560,7 @@ class PromptServer(): buffer.seek(0) return web.Response(body=buffer.read(), content_type=f'image/{image_format}', - headers={"Content-Disposition": f"attachment; filename=\"{filename}\""}) + headers={"Content-Disposition": f"filename=\"{filename}\""}) if 'channel' not in request.rel_url.query: channel = 'rgba' @@ -580,7 +580,7 @@ class PromptServer(): buffer.seek(0) return web.Response(body=buffer.read(), content_type='image/png', - headers={"Content-Disposition": f"attachment; filename=\"{filename}\""}) + headers={"Content-Disposition": f"filename=\"{filename}\""}) elif channel == 'a': with Image.open(file) as img: @@ -597,7 +597,7 @@ class PromptServer(): alpha_buffer.seek(0) return web.Response(body=alpha_buffer.read(), content_type='image/png', - headers={"Content-Disposition": f"attachment; filename=\"{filename}\""}) + headers={"Content-Disposition": f"filename=\"{filename}\""}) else: # Use the content type from asset resolution if available, # otherwise guess from the filename. @@ -614,7 +614,7 @@ class PromptServer(): return web.FileResponse( file, headers={ - "Content-Disposition": f"attachment; filename=\"{filename}\"", + "Content-Disposition": f"filename=\"{filename}\"", "Content-Type": content_type } ) From cd8c7a2306be98bf93cd6632384a675afe750a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jukka=20Sepp=C3=A4nen?= <40791699+kijai@users.noreply.github.com> Date: Thu, 7 May 2026 05:41:13 +0300 Subject: [PATCH 31/56] Throttle dynamic VRAM prepare logging (#13704) --- comfy/model_patcher.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/comfy/model_patcher.py b/comfy/model_patcher.py index 7d2d6883f..33bdedfb1 100644 --- a/comfy/model_patcher.py +++ b/comfy/model_patcher.py @@ -26,6 +26,7 @@ import uuid from typing import Callable, Optional import torch +import tqdm import comfy.float import comfy.hooks @@ -1651,7 +1652,11 @@ class ModelPatcherDynamic(ModelPatcher): self.model.model_loaded_weight_memory += casted_buf.numel() * casted_buf.element_size() force_load_stat = f" Force pre-loaded {len(self.backup)} weights: {self.model.model_loaded_weight_memory // 1024} KB." if len(self.backup) > 0 else "" - logging.info(f"Model {self.model.__class__.__name__} prepared for dynamic VRAM loading. {allocated_size // (1024 ** 2)}MB Staged. {num_patches} patches attached.{force_load_stat}") + log_key = (self.patches_uuid, allocated_size, num_patches, len(self.backup), self.model.model_loaded_weight_memory) + in_loop = bool(getattr(tqdm.tqdm, "_instances", None)) + level = logging.DEBUG if in_loop and getattr(self, "_last_prepare_log_key", None) == log_key else logging.INFO + self._last_prepare_log_key = log_key + logging.log(level, f"Model {self.model.__class__.__name__} prepared for dynamic VRAM loading. {allocated_size // (1024 ** 2)}MB Staged. {num_patches} patches attached.{force_load_stat}") self.model.device = device_to self.model.current_weight_patches_uuid = self.patches_uuid From e35348aa53563cabdcd9e5f67d0cb77b5259c903 Mon Sep 17 00:00:00 2001 From: comfyanonymous <121283862+comfyanonymous@users.noreply.github.com> Date: Wed, 6 May 2026 19:51:01 -0700 Subject: [PATCH 32/56] Add .comfy_environment to portable. (#13746) --- .github/workflows/stable-release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml index f501b7b31..bc64ed74d 100644 --- a/.github/workflows/stable-release.yml +++ b/.github/workflows/stable-release.yml @@ -145,6 +145,8 @@ jobs: cp -r ComfyUI/.ci/windows_${{ inputs.rel_name }}_base_files/* ./ cp ../update_comfyui_and_python_dependencies.bat ./update/ + echo 'local-portable' > ComfyUI/.comfy_environment + cd .. "C:\Program Files\7-Zip\7z.exe" a -t7z -m0=lzma2 -mx=9 -mfb=128 -md=768m -ms=on -mf=BCJ2 ComfyUI_windows_portable.7z ComfyUI_windows_portable From 1b25f1289e6f48081b727083425791876ed0f39b Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Thu, 7 May 2026 09:45:59 +0300 Subject: [PATCH 33/56] [Partner Nodes] add grok-imagine-image-quality model (#13725) * feat(api-nodes): add grok-imagine-image-quality model Signed-off-by: bigcat88 * fixed price badges Signed-off-by: bigcat88 * fix: adjust price badges Signed-off-by: bigcat88 --------- Signed-off-by: bigcat88 Co-authored-by: Jedrzej Kosinski --- comfy_api_nodes/nodes_grok.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/comfy_api_nodes/nodes_grok.py b/comfy_api_nodes/nodes_grok.py index f42d84616..dd5d7e249 100644 --- a/comfy_api_nodes/nodes_grok.py +++ b/comfy_api_nodes/nodes_grok.py @@ -54,7 +54,12 @@ class GrokImageNode(IO.ComfyNode): inputs=[ IO.Combo.Input( "model", - options=["grok-imagine-image-pro", "grok-imagine-image", "grok-imagine-image-beta"], + options=[ + "grok-imagine-image-quality", + "grok-imagine-image-pro", + "grok-imagine-image", + "grok-imagine-image-beta", + ], ), IO.String.Input( "prompt", @@ -111,10 +116,12 @@ class GrokImageNode(IO.ComfyNode): ], is_api_node=True, price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["model", "number_of_images"]), + depends_on=IO.PriceBadgeDepends(widgets=["model", "number_of_images", "resolution"]), expr=""" ( - $rate := $contains(widgets.model, "pro") ? 0.07 : 0.02; + $rate := widgets.model = "grok-imagine-image-quality" + ? (widgets.resolution = "1k" ? 0.05 : 0.07) + : ($contains(widgets.model, "pro") ? 0.07 : 0.02); {"type":"usd","usd": $rate * widgets.number_of_images} ) """, @@ -167,7 +174,12 @@ class GrokImageEditNode(IO.ComfyNode): inputs=[ IO.Combo.Input( "model", - options=["grok-imagine-image-pro", "grok-imagine-image", "grok-imagine-image-beta"], + options=[ + "grok-imagine-image-quality", + "grok-imagine-image-pro", + "grok-imagine-image", + "grok-imagine-image-beta", + ], ), IO.Image.Input("image", display_name="images"), IO.String.Input( @@ -228,11 +240,19 @@ class GrokImageEditNode(IO.ComfyNode): ], is_api_node=True, price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["model", "number_of_images"]), + depends_on=IO.PriceBadgeDepends(widgets=["model", "number_of_images", "resolution"]), expr=""" ( - $rate := $contains(widgets.model, "pro") ? 0.07 : 0.02; - {"type":"usd","usd": 0.002 + $rate * widgets.number_of_images} + $isQualityModel := widgets.model = "grok-imagine-image-quality"; + $isPro := $contains(widgets.model, "pro"); + $rate := $isQualityModel + ? (widgets.resolution = "1k" ? 0.05 : 0.07) + : ($isPro ? 0.07 : 0.02); + $base := $isQualityModel ? 0.01 : 0.002; + $output := $rate * widgets.number_of_images; + $isPro + ? {"type":"usd","usd": $base + $output} + : {"type":"range_usd","min_usd": $base + $output, "max_usd": 3 * $base + $output} ) """, ), From 25757a53c93281e8e2462ced8795373f09e675bf Mon Sep 17 00:00:00 2001 From: "Daxiong (Lin)" Date: Thu, 7 May 2026 16:28:18 +0900 Subject: [PATCH 34/56] chore: update workflow templates to v0.9.72 (#13732) Co-authored-by: Jedrzej Kosinski --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e7aa92c31..5c7ff76be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ comfyui-frontend-package==1.43.17 -comfyui-workflow-templates==0.9.69 +comfyui-workflow-templates==0.9.72 comfyui-embedded-docs==0.4.4 torch torchsde From c945a433ae09423f7a2a6e9631538e55b9375f78 Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Thu, 7 May 2026 21:55:09 +0300 Subject: [PATCH 35/56] fix(api-nodes): fixed price badge for Kling V3 model in the Motion Control node (#13790) Signed-off-by: bigcat88 --- comfy_api_nodes/nodes_kling.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/comfy_api_nodes/nodes_kling.py b/comfy_api_nodes/nodes_kling.py index efd58fac3..7586f1816 100644 --- a/comfy_api_nodes/nodes_kling.py +++ b/comfy_api_nodes/nodes_kling.py @@ -2787,11 +2787,15 @@ class MotionControl(IO.ComfyNode): ], is_api_node=True, price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["mode"]), + depends_on=IO.PriceBadgeDepends(widgets=["mode", "model"]), expr=""" ( - $prices := {"std": 0.07, "pro": 0.112}; - {"type":"usd","usd": $lookup($prices, widgets.mode), "format":{"suffix":"/second"}} + $prices := { + "kling-v3": {"std": 0.126, "pro": 0.168}, + "kling-v2-6": {"std": 0.07, "pro": 0.112} + }; + $modelPrices := $lookup($prices, widgets.model); + {"type":"usd","usd": $lookup($modelPrices, widgets.mode), "format":{"suffix":"/second"}} ) """, ), From c011fb520c79b9dfbe7f885d613771774f746eef Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Thu, 7 May 2026 22:19:44 +0300 Subject: [PATCH 36/56] [Partner Nodes] new NanoBanana2 node with DynamicCombo/Autogrow (#13753) * feat(api-nodes): new NanoBanana2 node with DynamicCombo/Autogrow Signed-off-by: bigcat88 * feat: improved status text on uploading Signed-off-by: bigcat88 * feat: improved status text on uploading (2) Signed-off-by: bigcat88 --------- Signed-off-by: bigcat88 --- comfy_api_nodes/nodes_gemini.py | 242 +++++++++++++++++++++++++++++--- 1 file changed, 222 insertions(+), 20 deletions(-) diff --git a/comfy_api_nodes/nodes_gemini.py b/comfy_api_nodes/nodes_gemini.py index 2b77a022e..d18c958a8 100644 --- a/comfy_api_nodes/nodes_gemini.py +++ b/comfy_api_nodes/nodes_gemini.py @@ -83,13 +83,16 @@ class GeminiImageModel(str, Enum): async def create_image_parts( cls: type[IO.ComfyNode], - images: Input.Image, + images: Input.Image | list[Input.Image], image_limit: int = 0, ) -> list[GeminiPart]: image_parts: list[GeminiPart] = [] if image_limit < 0: raise ValueError("image_limit must be greater than or equal to 0 when creating Gemini image parts.") - total_images = get_number_of_images(images) + + # Accept either a single (possibly-batched) tensor or a list of them; share URL budget across all. + images_list: list[Input.Image] = images if isinstance(images, list) else [images] + total_images = sum(get_number_of_images(img) for img in images_list) if total_images <= 0: raise ValueError("No images provided to create_image_parts; at least one image is required.") @@ -98,10 +101,18 @@ async def create_image_parts( # Number of images we'll send as URLs (fileData) num_url_images = min(effective_max, 10) # Vertex API max number of image links + upload_kwargs: dict = {"wait_label": "Uploading reference images"} + if effective_max > num_url_images: + # Split path (e.g. 11+ images): suppress per-image counter to avoid a confusing dual-fraction label. + upload_kwargs = { + "wait_label": f"Uploading reference images ({num_url_images}+)", + "show_batch_index": False, + } reference_images_urls = await upload_images_to_comfyapi( cls, - images, + images_list, max_images=num_url_images, + **upload_kwargs, ) for reference_image_url in reference_images_urls: image_parts.append( @@ -112,15 +123,22 @@ async def create_image_parts( ) ) ) - for idx in range(num_url_images, effective_max): - image_parts.append( - GeminiPart( - inlineData=GeminiInlineData( - mimeType=GeminiMimeType.image_png, - data=tensor_to_base64_string(images[idx]), + if effective_max > num_url_images: + flat: list[torch.Tensor] = [] + for tensor in images_list: + if len(tensor.shape) == 4: + flat.extend(tensor[i] for i in range(tensor.shape[0])) + else: + flat.append(tensor) + for idx in range(num_url_images, effective_max): + image_parts.append( + GeminiPart( + inlineData=GeminiInlineData( + mimeType=GeminiMimeType.image_png, + data=tensor_to_base64_string(flat[idx]), + ) ) ) - ) return image_parts @@ -891,10 +909,6 @@ class GeminiNanoBanana2(IO.ComfyNode): "9:16", "16:9", "21:9", - # "1:4", - # "4:1", - # "8:1", - # "1:8", ], default="auto", tooltip="If set to 'auto', matches your input image's aspect ratio; " @@ -902,12 +916,7 @@ class GeminiNanoBanana2(IO.ComfyNode): ), IO.Combo.Input( "resolution", - options=[ - # "512px", - "1K", - "2K", - "4K", - ], + options=["1K", "2K", "4K"], tooltip="Target output resolution. For 2K/4K the native Gemini upscaler is used.", ), IO.Combo.Input( @@ -956,6 +965,7 @@ class GeminiNanoBanana2(IO.ComfyNode): ], is_api_node=True, price_badge=GEMINI_IMAGE_2_PRICE_BADGE, + is_deprecated=True, ) @classmethod @@ -1016,6 +1026,197 @@ class GeminiNanoBanana2(IO.ComfyNode): ) +def _nano_banana_2_v2_model_inputs(): + return [ + IO.Combo.Input( + "aspect_ratio", + options=[ + "auto", + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", + "1:4", + "4:1", + "8:1", + "1:8", + ], + default="auto", + tooltip="If set to 'auto', matches your input image's aspect ratio; " + "if no image is provided, a 16:9 square is usually generated.", + ), + IO.Combo.Input( + "resolution", + options=["1K", "2K", "4K"], + tooltip="Target output resolution. For 2K/4K the native Gemini upscaler is used.", + ), + IO.Combo.Input( + "thinking_level", + options=["MINIMAL", "HIGH"], + ), + IO.Autogrow.Input( + "images", + template=IO.Autogrow.TemplateNames( + IO.Image.Input("image"), + names=[f"image_{i}" for i in range(1, 15)], + min=0, + ), + tooltip="Optional reference image(s). Up to 14 images total.", + ), + IO.Custom("GEMINI_INPUT_FILES").Input( + "files", + optional=True, + tooltip="Optional file(s) to use as context for the model. " + "Accepts inputs from the Gemini Generate Content Input Files node.", + ), + ] + + +class GeminiNanoBanana2V2(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="GeminiNanoBanana2V2", + display_name="Nano Banana 2", + category="api node/image/Gemini", + description="Generate or edit images synchronously via Google Vertex API.", + inputs=[ + IO.String.Input( + "prompt", + multiline=True, + tooltip="Text prompt describing the image to generate or the edits to apply. " + "Include any constraints, styles, or details the model should follow.", + default="", + ), + IO.DynamicCombo.Input( + "model", + options=[ + IO.DynamicCombo.Option( + "Nano Banana 2 (Gemini 3.1 Flash Image)", + _nano_banana_2_v2_model_inputs(), + ), + ], + ), + IO.Int.Input( + "seed", + default=42, + min=0, + max=0xFFFFFFFFFFFFFFFF, + control_after_generate=True, + tooltip="When the seed is fixed to a specific value, the model makes a best effort to provide " + "the same response for repeated requests. Deterministic output isn't guaranteed. " + "Also, changing the model or parameter settings, such as the temperature, " + "can cause variations in the response even when you use the same seed value. " + "By default, a random seed value is used.", + ), + IO.Combo.Input( + "response_modalities", + options=["IMAGE", "IMAGE+TEXT"], + advanced=True, + ), + IO.String.Input( + "system_prompt", + multiline=True, + default=GEMINI_IMAGE_SYS_PROMPT, + optional=True, + tooltip="Foundational instructions that dictate an AI's behavior.", + advanced=True, + ), + ], + outputs=[ + IO.Image.Output(), + IO.String.Output(), + IO.Image.Output( + display_name="thought_image", + tooltip="First image from the model's thinking process. " + "Only available with thinking_level HIGH and IMAGE+TEXT modality.", + ), + ], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["model", "model.resolution"]), + expr=""" + ( + $r := $lookup(widgets, "model.resolution"); + $prices := {"1k": 0.0696, "2k": 0.1014, "4k": 0.154}; + {"type":"usd","usd": $lookup($prices, $r), "format":{"suffix":"/Image","approximate":true}} + ) + """, + ), + ) + + @classmethod + async def execute( + cls, + prompt: str, + model: dict, + seed: int, + response_modalities: str, + system_prompt: str = "", + ) -> IO.NodeOutput: + validate_string(prompt, strip_whitespace=True, min_length=1) + model_choice = model["model"] + if model_choice == "Nano Banana 2 (Gemini 3.1 Flash Image)": + model_id = "gemini-3.1-flash-image-preview" + else: + model_id = model_choice + + images = model.get("images") or {} + parts: list[GeminiPart] = [GeminiPart(text=prompt)] + if images: + image_tensors: list[Input.Image] = [t for t in images.values() if t is not None] + if image_tensors: + if sum(get_number_of_images(t) for t in image_tensors) > 14: + raise ValueError("The current maximum number of supported images is 14.") + parts.extend(await create_image_parts(cls, image_tensors)) + files = model.get("files") + if files is not None: + parts.extend(files) + + image_config = GeminiImageConfig(imageSize=model["resolution"]) + if model["aspect_ratio"] != "auto": + image_config.aspectRatio = model["aspect_ratio"] + + gemini_system_prompt = None + if system_prompt: + gemini_system_prompt = GeminiSystemInstructionContent(parts=[GeminiTextPart(text=system_prompt)], role=None) + + response = await sync_op( + cls, + ApiEndpoint(path=f"/proxy/vertexai/gemini/{model_id}", method="POST"), + data=GeminiImageGenerateContentRequest( + contents=[ + GeminiContent(role=GeminiRole.user, parts=parts), + ], + generationConfig=GeminiImageGenerationConfig( + responseModalities=(["IMAGE"] if response_modalities == "IMAGE" else ["TEXT", "IMAGE"]), + imageConfig=image_config, + thinkingConfig=GeminiThinkingConfig(thinkingLevel=model["thinking_level"]), + ), + systemInstruction=gemini_system_prompt, + ), + response_model=GeminiGenerateContentResponse, + price_extractor=calculate_tokens_price, + ) + return IO.NodeOutput( + await get_image_from_response(response), + get_text_from_response(response), + await get_image_from_response(response, thought=True), + ) + + class GeminiExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -1024,6 +1225,7 @@ class GeminiExtension(ComfyExtension): GeminiImage, GeminiImage2, GeminiNanoBanana2, + GeminiNanoBanana2V2, GeminiInputFiles, ] From 8dc3f3f2094121c0a013e21d89136ebc331d2974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jukka=20Sepp=C3=A4nen?= <40791699+kijai@users.noreply.github.com> Date: Fri, 8 May 2026 03:18:28 +0300 Subject: [PATCH 37/56] Improve SAM3 large input handling (#13767) --- comfy/ldm/sam3/detector.py | 9 ++++--- comfy/ldm/sam3/tracker.py | 49 +++++++++++++++++++++++++------------- comfy_extras/nodes_sam3.py | 24 +++++++++++-------- 3 files changed, 53 insertions(+), 29 deletions(-) diff --git a/comfy/ldm/sam3/detector.py b/comfy/ldm/sam3/detector.py index 12d3a01ab..23a972ac7 100644 --- a/comfy/ldm/sam3/detector.py +++ b/comfy/ldm/sam3/detector.py @@ -561,7 +561,8 @@ class SAM3Model(nn.Module): return high_res_masks def forward_video(self, images, initial_masks, pbar=None, text_prompts=None, - new_det_thresh=0.5, max_objects=0, detect_interval=1): + new_det_thresh=0.5, max_objects=0, detect_interval=1, + target_device=None, target_dtype=None): """Track video with optional per-frame text-prompted detection.""" bb = self.detector.backbone["vision_backbone"] @@ -589,8 +590,10 @@ class SAM3Model(nn.Module): return self.tracker.track_video_with_detection( backbone_fn, images, initial_masks, detect_fn, new_det_thresh=new_det_thresh, max_objects=max_objects, - detect_interval=detect_interval, backbone_obj=bb, pbar=pbar) + detect_interval=detect_interval, backbone_obj=bb, pbar=pbar, + target_device=target_device, target_dtype=target_dtype) # SAM3 (non-multiplex) — no detection support, requires initial masks if initial_masks is None: raise ValueError("SAM3 (non-multiplex) requires initial_mask for video tracking") - return self.tracker.track_video(backbone_fn, images, initial_masks, pbar=pbar, backbone_obj=bb) + return self.tracker.track_video(backbone_fn, images, initial_masks, pbar=pbar, backbone_obj=bb, + target_device=target_device, target_dtype=target_dtype) diff --git a/comfy/ldm/sam3/tracker.py b/comfy/ldm/sam3/tracker.py index 8f7481003..8456e90a6 100644 --- a/comfy/ldm/sam3/tracker.py +++ b/comfy/ldm/sam3/tracker.py @@ -200,8 +200,13 @@ def pack_masks(masks): def unpack_masks(packed): """Unpack bit-packed [*, H, W//8] uint8 to bool [*, H, W*8].""" - shifts = torch.arange(8, device=packed.device) - return ((packed.unsqueeze(-1) >> shifts) & 1).view(*packed.shape[:-1], -1).bool() + bits = torch.tensor([1, 2, 4, 8, 16, 32, 64, 128], dtype=torch.uint8, device=packed.device) + return (packed.unsqueeze(-1) & bits).bool().view(*packed.shape[:-1], -1) + + +def _prep_frame(images, idx, device, dt, size): + """Slice CPU full-res frames, transfer to GPU in target dtype, and resize to (size, size).""" + return comfy.utils.common_upscale(images[idx].to(device=device, dtype=dt), size, size, "bicubic", crop="disabled") def _compute_backbone(backbone_fn, frame, frame_idx=None): @@ -1078,16 +1083,19 @@ class SAM3Tracker(nn.Module): # SAM3: drop last FPN level return vision_feats[:-1], vision_pos[:-1], feat_sizes[:-1] - def _track_single_object(self, backbone_fn, images, initial_mask, pbar=None): + def _track_single_object(self, backbone_fn, images, initial_mask, pbar=None, + target_device=None, target_dtype=None): """Track one object, computing backbone per frame to save VRAM.""" N = images.shape[0] - device, dt = images.device, images.dtype + device = target_device if target_device is not None else images.device + dt = target_dtype if target_dtype is not None else images.dtype + size = self.image_size output_dict = {"cond_frame_outputs": {}, "non_cond_frame_outputs": {}} all_masks = [] for frame_idx in tqdm(range(N), desc="tracking"): vision_feats, vision_pos, feat_sizes = self._compute_backbone_frame( - backbone_fn, images[frame_idx:frame_idx + 1], frame_idx=frame_idx) + backbone_fn, _prep_frame(images, slice(frame_idx, frame_idx + 1), device, dt, size), frame_idx=frame_idx) mask_input = None if frame_idx == 0: mask_input = F.interpolate(initial_mask.to(device=device, dtype=dt), @@ -1114,12 +1122,13 @@ class SAM3Tracker(nn.Module): return torch.cat(all_masks, dim=0) # [N, 1, H, W] - def track_video(self, backbone_fn, images, initial_masks, pbar=None, **kwargs): + def track_video(self, backbone_fn, images, initial_masks, pbar=None, + target_device=None, target_dtype=None, **kwargs): """Track one or more objects across video frames. Args: backbone_fn: callable that returns (sam2_features, sam2_positions, trunk_out) for a frame - images: [N, 3, 1008, 1008] video frames + images: [N, 3, H, W] CPU full-res video frames (resized per-frame to self.image_size) initial_masks: [N_obj, 1, H, W] binary masks for first frame (one per object) pbar: optional progress bar @@ -1130,7 +1139,8 @@ class SAM3Tracker(nn.Module): per_object = [] for obj_idx in range(N_obj): obj_masks = self._track_single_object( - backbone_fn, images, initial_masks[obj_idx:obj_idx + 1], pbar=pbar) + backbone_fn, images, initial_masks[obj_idx:obj_idx + 1], pbar=pbar, + target_device=target_device, target_dtype=target_dtype) per_object.append(obj_masks) return torch.cat(per_object, dim=1) # [N, N_obj, H, W] @@ -1632,11 +1642,18 @@ class SAM31Tracker(nn.Module): return det_scores[new_dets].tolist() if det_scores is not None else [0.0] * new_dets.sum().item() return [] + INTERNAL_MAX_OBJECTS = 64 # Hard ceiling on accumulated tracks; max_objects=0 or any value above this is clamped here. + def track_video_with_detection(self, backbone_fn, images, initial_masks, detect_fn=None, new_det_thresh=0.5, max_objects=0, detect_interval=1, - backbone_obj=None, pbar=None): + backbone_obj=None, pbar=None, target_device=None, target_dtype=None): """Track with optional per-frame detection. Returns [N, max_N_obj, H, W] mask logits.""" - N, device, dt = images.shape[0], images.device, images.dtype + if max_objects <= 0 or max_objects > self.INTERNAL_MAX_OBJECTS: + max_objects = self.INTERNAL_MAX_OBJECTS + N = images.shape[0] + device = target_device if target_device is not None else images.device + dt = target_dtype if target_dtype is not None else images.dtype + size = self.image_size output_dict = {"cond_frame_outputs": {}, "non_cond_frame_outputs": {}} all_masks = [] idev = comfy.model_management.intermediate_device() @@ -1656,7 +1673,7 @@ class SAM31Tracker(nn.Module): prefetch = True except RuntimeError: pass - cur_bb = self._compute_backbone_frame(backbone_fn, images[0:1], frame_idx=0) + cur_bb = self._compute_backbone_frame(backbone_fn, _prep_frame(images, slice(0, 1), device, dt, size), frame_idx=0) for frame_idx in tqdm(range(N), desc="tracking"): vision_feats, vision_pos, feat_sizes, high_res_prop, trunk_out = cur_bb @@ -1666,7 +1683,7 @@ class SAM31Tracker(nn.Module): backbone_stream.wait_stream(torch.cuda.current_stream(device)) with torch.cuda.stream(backbone_stream): next_bb = self._compute_backbone_frame( - backbone_fn, images[frame_idx + 1:frame_idx + 2], frame_idx=frame_idx + 1) + backbone_fn, _prep_frame(images, slice(frame_idx + 1, frame_idx + 2), device, dt, size), frame_idx=frame_idx + 1) # Per-frame detection with NMS (skip if no detect_fn, or interval/max not met) det_masks = torch.empty(0, device=device) @@ -1687,7 +1704,7 @@ class SAM31Tracker(nn.Module): current_out = self._condition_with_masks( initial_masks.to(device=device, dtype=dt), frame_idx, vision_feats, vision_pos, feat_sizes, high_res_prop, output_dict, N, mux_state, backbone_obj, - images[frame_idx:frame_idx + 1], trunk_out) + _prep_frame(images, slice(frame_idx, frame_idx + 1), device, dt, size), trunk_out) last_occluded = torch.full((mux_state.total_valid_entries,), -1, device=device, dtype=torch.long) obj_scores = [1.0] * mux_state.total_valid_entries if keep_alive is not None: @@ -1702,7 +1719,7 @@ class SAM31Tracker(nn.Module): current_out = self._condition_with_masks( det_masks, frame_idx, vision_feats, vision_pos, feat_sizes, high_res_prop, output_dict, N, mux_state, backbone_obj, - images[frame_idx:frame_idx + 1], trunk_out, threshold=0.0) + _prep_frame(images, slice(frame_idx, frame_idx + 1), device, dt, size), trunk_out, threshold=0.0) last_occluded = torch.full((mux_state.total_valid_entries,), -1, device=device, dtype=torch.long) obj_scores = det_scores[:mux_state.total_valid_entries].tolist() if keep_alive is not None: @@ -1718,7 +1735,7 @@ class SAM31Tracker(nn.Module): torch.cuda.current_stream(device).wait_stream(backbone_stream) cur_bb = next_bb else: - cur_bb = self._compute_backbone_frame(backbone_fn, images[frame_idx + 1:frame_idx + 2], frame_idx=frame_idx + 1) + cur_bb = self._compute_backbone_frame(backbone_fn, _prep_frame(images, slice(frame_idx + 1, frame_idx + 2), device, dt, size), frame_idx=frame_idx + 1) continue else: N_obj = mux_state.total_valid_entries @@ -1768,7 +1785,7 @@ class SAM31Tracker(nn.Module): torch.cuda.current_stream(device).wait_stream(backbone_stream) cur_bb = next_bb else: - cur_bb = self._compute_backbone_frame(backbone_fn, images[frame_idx + 1:frame_idx + 2], frame_idx=frame_idx + 1) + cur_bb = self._compute_backbone_frame(backbone_fn, _prep_frame(images, slice(frame_idx + 1, frame_idx + 2), device, dt, size), frame_idx=frame_idx + 1) if not all_masks or all(m is None for m in all_masks): return {"packed_masks": None, "n_frames": N, "scores": []} diff --git a/comfy_extras/nodes_sam3.py b/comfy_extras/nodes_sam3.py index 5cf92ccb3..c460506bf 100644 --- a/comfy_extras/nodes_sam3.py +++ b/comfy_extras/nodes_sam3.py @@ -272,8 +272,8 @@ class SAM3_VideoTrack(io.ComfyNode): io.Model.Input("model", display_name="model"), io.Mask.Input("initial_mask", display_name="initial_mask", optional=True, tooltip="Mask(s) for the first frame to track (one per object)"), io.Conditioning.Input("conditioning", display_name="conditioning", optional=True, tooltip="Text conditioning for detecting new objects during tracking"), - io.Float.Input("detection_threshold", display_name="detection_threshold", default=0.5, min=0.0, max=1.0, step=0.01, tooltip="Score threshold for text-prompted detection"), - io.Int.Input("max_objects", display_name="max_objects", default=0, min=0, tooltip="Max tracked objects (0=unlimited). Initial masks count toward this limit."), + io.Float.Input("detection_threshold", display_name="detection_threshold", default=0.5, min=0.0, max=1.0, step=0.01, tooltip="Score threshold for text-prompted detection."), + io.Int.Input("max_objects", display_name="max_objects", default=4, min=0, max=64, tooltip="Max tracked objects. Initial masks count toward this limit. 0 uses the internal cap of 64."), io.Int.Input("detect_interval", display_name="detect_interval", default=1, min=1, tooltip="Run detection every N frames (1=every frame). Higher values save compute."), ], outputs=[ @@ -290,8 +290,7 @@ class SAM3_VideoTrack(io.ComfyNode): dtype = model.model.get_dtype() sam3_model = model.model.diffusion_model - frames = images[..., :3].movedim(-1, 1) - frames_in = comfy.utils.common_upscale(frames, 1008, 1008, "bilinear", crop="disabled").to(device=device, dtype=dtype) + frames_in = images[..., :3].movedim(-1, 1) init_masks = None if initial_mask is not None: @@ -308,7 +307,7 @@ class SAM3_VideoTrack(io.ComfyNode): result = sam3_model.forward_video( images=frames_in, initial_masks=init_masks, pbar=pbar, text_prompts=text_prompts, new_det_thresh=detection_threshold, max_objects=max_objects, - detect_interval=detect_interval) + detect_interval=detect_interval, target_device=device, target_dtype=dtype) result["orig_size"] = (H, W) return io.NodeOutput(result) @@ -449,14 +448,18 @@ class SAM3_TrackPreview(io.ComfyNode): cx = (bool_masks * grid_x).sum(dim=(-1, -2)) // area has = area > 1 scores = track_data.get("scores", []) + label_scale = max(3, H // 240) # Scale font with resolutio + size_caps = (area.float().sqrt() / 15).clamp_(min=1).long().tolist() #cap per-object so the number doesn't dwarf small masks for obj_idx in range(N_obj): if has[obj_idx]: _cx, _cy = int(cx[obj_idx]), int(cy[obj_idx]) color = cls.COLORS[obj_idx % len(cls.COLORS)] - SAM3_TrackPreview._draw_number_gpu(frame_gpu, obj_idx, _cx, _cy, color) + obj_scale = min(label_scale, size_caps[obj_idx]) + score_scale = max(1, obj_scale * 2 // 3) + SAM3_TrackPreview._draw_number_gpu(frame_gpu, obj_idx, _cx, _cy, color, scale=obj_scale) if obj_idx < len(scores) and scores[obj_idx] < 1.0: SAM3_TrackPreview._draw_number_gpu(frame_gpu, int(scores[obj_idx] * 100), - _cx, _cy + 5 * 3 + 3, color, scale=2) + _cx, _cy + 5 * obj_scale + 3, color, scale=score_scale) frame_cpu.copy_(frame_gpu.clamp_(0, 1).mul_(255).byte()) else: frame_cpu.copy_(frame.clamp_(0, 1).mul_(255).byte()) @@ -507,9 +510,10 @@ class SAM3_TrackToMask(io.ComfyNode): if not indices: return io.NodeOutput(torch.zeros(N, H, W, device=comfy.model_management.intermediate_device())) - selected = packed[:, indices] - binary = unpack_masks(selected) # [N, len(indices), Hm, Wm] bool - union = binary.any(dim=1, keepdim=True).float() + union_packed = packed[:, indices[0]].clone() + for i in indices[1:]: + union_packed |= packed[:, i] + union = unpack_masks(union_packed).unsqueeze(1).float() # [N, 1, Hm, Wm] mask_out = F.interpolate(union, size=(H, W), mode="bilinear", align_corners=False)[:, 0] return io.NodeOutput(mask_out) From ef8f25601a8504647caf9c9213a7c41a9f414901 Mon Sep 17 00:00:00 2001 From: Talmaj Date: Fri, 8 May 2026 03:38:36 +0200 Subject: [PATCH 38/56] Add I2V for causal forcing model. (#13719) --- comfy/k_diffusion/sampling.py | 17 +++++++++++ comfy_extras/nodes_ar_video.py | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/comfy/k_diffusion/sampling.py b/comfy/k_diffusion/sampling.py index d33bc7199..c53ac4b2b 100644 --- a/comfy/k_diffusion/sampling.py +++ b/comfy/k_diffusion/sampling.py @@ -1859,6 +1859,23 @@ def sample_ar_video(model, x, sigmas, extra_args=None, callback=None, disable=No output = torch.zeros_like(x) s_in = x.new_ones([x.shape[0]]) current_start_frame = 0 + + # I2V: seed KV cache with the initial image latent before the denoising loop + initial_latent = transformer_options.get("ar_config", {}).get("initial_latent", None) + if initial_latent is not None: + initial_latent = inner_model.process_latent_in(initial_latent).to(device=device, dtype=model_dtype) + n_init = initial_latent.shape[2] + output[:, :, :n_init] = initial_latent + + ar_state = {"start_frame": 0, "kv_caches": kv_caches, "crossattn_caches": crossattn_caches} + transformer_options["ar_state"] = ar_state + zero_sigma = sigmas.new_zeros([1]) + _ = model(initial_latent, zero_sigma * s_in, **extra_args) + + current_start_frame = n_init + remaining = lat_t - n_init + num_blocks = -(-remaining // num_frame_per_block) + num_sigma_steps = len(sigmas) - 1 total_real_steps = num_blocks * num_sigma_steps step_count = 0 diff --git a/comfy_extras/nodes_ar_video.py b/comfy_extras/nodes_ar_video.py index 09ee886fd..b36588b14 100644 --- a/comfy_extras/nodes_ar_video.py +++ b/comfy_extras/nodes_ar_video.py @@ -2,6 +2,7 @@ ComfyUI nodes for autoregressive video generation (Causal Forcing, Self-Forcing, etc.). - EmptyARVideoLatent: create 5D [B, C, T, H, W] video latent tensors - SamplerARVideo: SAMPLER for the block-by-block autoregressive denoising loop + - ARVideoI2V: image-to-video conditioning for AR models (seeds KV cache with start image) """ import torch @@ -9,6 +10,7 @@ from typing_extensions import override import comfy.model_management import comfy.samplers +import comfy.utils from comfy_api.latest import ComfyExtension, io @@ -71,12 +73,62 @@ class SamplerARVideo(io.ComfyNode): return io.NodeOutput(comfy.samplers.ksampler("ar_video", extra_options)) +class ARVideoI2V(io.ComfyNode): + """Image-to-video setup for AR video models (Causal Forcing, Self-Forcing). + + VAE-encodes the start image and stores it in the model's transformer_options + so that sample_ar_video can seed the KV cache before denoising. + Uses the same T2V model checkpoint -- no separate I2V architecture needed. + """ + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="ARVideoI2V", + category="conditioning/video_models", + inputs=[ + io.Model.Input("model"), + io.Vae.Input("vae"), + io.Image.Input("start_image"), + io.Int.Input("width", default=832, min=16, max=8192, step=16), + io.Int.Input("height", default=480, min=16, max=8192, step=16), + io.Int.Input("length", default=81, min=1, max=1024, step=4), + io.Int.Input("batch_size", default=1, min=1, max=64), + ], + outputs=[ + io.Model.Output(display_name="MODEL"), + io.Latent.Output(display_name="LATENT"), + ], + ) + + @classmethod + def execute(cls, model, vae, start_image, width, height, length, batch_size) -> io.NodeOutput: + start_image = comfy.utils.common_upscale( + start_image[:1].movedim(-1, 1), width, height, "bilinear", "center" + ).movedim(1, -1) + + initial_latent = vae.encode(start_image[:, :, :, :3]) + + m = model.clone() + to = m.model_options.setdefault("transformer_options", {}) + ar_cfg = to.setdefault("ar_config", {}) + ar_cfg["initial_latent"] = initial_latent + + lat_t = ((length - 1) // 4) + 1 + latent = torch.zeros( + [batch_size, 16, lat_t, height // 8, width // 8], + device=comfy.model_management.intermediate_device(), + ) + return io.NodeOutput(m, {"samples": latent}) + + class ARVideoExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ EmptyARVideoLatent, SamplerARVideo, + ARVideoI2V, ] From df7bf1d3dc852365593786497123d92440ac1852 Mon Sep 17 00:00:00 2001 From: comfyanonymous <121283862+comfyanonymous@users.noreply.github.com> Date: Thu, 7 May 2026 19:04:30 -0700 Subject: [PATCH 39/56] Update warning message for ComfyUI frontend installation. (#13796) --- app/frontend_management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend_management.py b/app/frontend_management.py index f753ef0de..7108bd35a 100644 --- a/app/frontend_management.py +++ b/app/frontend_management.py @@ -27,7 +27,7 @@ def frontend_install_warning_message(): return f""" {get_missing_requirements_message()} -This error is happening because the ComfyUI frontend is no longer shipped as part of the main repo but as a pip package instead. +The ComfyUI frontend is shipped in a pip package so it needs to be updated separately from the ComfyUI code. """.strip() def parse_version(version: str) -> tuple[int, int, int]: From c8673542f762910766691345401e09caef2bc9a6 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Thu, 7 May 2026 19:21:12 -0700 Subject: [PATCH 40/56] fix: make NodeReplaceManager.register() idempotent (#13596) --- app/node_replace_manager.py | 20 ++++- .../app_test/node_replace_manager_test.py | 90 +++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests-unit/app_test/node_replace_manager_test.py diff --git a/app/node_replace_manager.py b/app/node_replace_manager.py index d9aab5b22..72e8ac2b1 100644 --- a/app/node_replace_manager.py +++ b/app/node_replace_manager.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging + from aiohttp import web from typing import TYPE_CHECKING, TypedDict @@ -31,8 +33,22 @@ class NodeReplaceManager: self._replacements: dict[str, list[NodeReplace]] = {} def register(self, node_replace: NodeReplace): - """Register a node replacement mapping.""" - self._replacements.setdefault(node_replace.old_node_id, []).append(node_replace) + """Register a node replacement mapping. + + Idempotent: if a replacement with the same (old_node_id, new_node_id) + is already registered, the duplicate is ignored. This prevents stale + entries from accumulating when custom nodes are reloaded in the same + process (e.g. via ComfyUI-Manager). + """ + existing = self._replacements.setdefault(node_replace.old_node_id, []) + for entry in existing: + if entry.new_node_id == node_replace.new_node_id: + logging.debug( + "Node replacement %s -> %s already registered, ignoring duplicate.", + node_replace.old_node_id, node_replace.new_node_id, + ) + return + existing.append(node_replace) def get_replacement(self, old_node_id: str) -> list[NodeReplace] | None: """Get replacements for an old node ID.""" diff --git a/tests-unit/app_test/node_replace_manager_test.py b/tests-unit/app_test/node_replace_manager_test.py new file mode 100644 index 000000000..8a3fd18bb --- /dev/null +++ b/tests-unit/app_test/node_replace_manager_test.py @@ -0,0 +1,90 @@ +"""Tests for NodeReplaceManager registration behavior.""" +import importlib +import sys +import types + +import pytest + + +@pytest.fixture +def NodeReplaceManager(monkeypatch): + """Provide NodeReplaceManager with `nodes` stubbed. + + `app.node_replace_manager` does `import nodes` at module level, which pulls in + torch + the full ComfyUI graph. register() doesn't actually need it, so we + stub `nodes` per-test (via monkeypatch so it's torn down) and reload the + module so it picks up the stub instead of any cached real import. + """ + fake_nodes = types.ModuleType("nodes") + fake_nodes.NODE_CLASS_MAPPINGS = {} + monkeypatch.setitem(sys.modules, "nodes", fake_nodes) + monkeypatch.delitem(sys.modules, "app.node_replace_manager", raising=False) + module = importlib.import_module("app.node_replace_manager") + yield module.NodeReplaceManager + # Drop the freshly-imported module so the next test (or a later real import + # of `nodes`) starts from a clean slate. + sys.modules.pop("app.node_replace_manager", None) + + +class FakeNodeReplace: + """Lightweight stand-in for comfy_api.latest._io.NodeReplace.""" + def __init__(self, new_node_id, old_node_id, old_widget_ids=None, + input_mapping=None, output_mapping=None): + self.new_node_id = new_node_id + self.old_node_id = old_node_id + self.old_widget_ids = old_widget_ids + self.input_mapping = input_mapping + self.output_mapping = output_mapping + + +def test_register_adds_replacement(NodeReplaceManager): + manager = NodeReplaceManager() + manager.register(FakeNodeReplace(new_node_id="NewNode", old_node_id="OldNode")) + assert manager.has_replacement("OldNode") + assert len(manager.get_replacement("OldNode")) == 1 + + +def test_register_allows_multiple_alternatives_for_same_old_node(NodeReplaceManager): + """Different new_node_ids for the same old_node_id should all be kept.""" + manager = NodeReplaceManager() + manager.register(FakeNodeReplace(new_node_id="AltA", old_node_id="OldNode")) + manager.register(FakeNodeReplace(new_node_id="AltB", old_node_id="OldNode")) + replacements = manager.get_replacement("OldNode") + assert len(replacements) == 2 + assert {r.new_node_id for r in replacements} == {"AltA", "AltB"} + + +def test_register_is_idempotent_for_duplicate_pair(NodeReplaceManager): + """Re-registering the same (old_node_id, new_node_id) should be a no-op.""" + manager = NodeReplaceManager() + manager.register(FakeNodeReplace(new_node_id="NewNode", old_node_id="OldNode")) + manager.register(FakeNodeReplace(new_node_id="NewNode", old_node_id="OldNode")) + manager.register(FakeNodeReplace(new_node_id="NewNode", old_node_id="OldNode")) + assert len(manager.get_replacement("OldNode")) == 1 + + +def test_register_idempotent_preserves_first_registration(NodeReplaceManager): + """First registration wins; later duplicates with different mappings are ignored.""" + manager = NodeReplaceManager() + first = FakeNodeReplace( + new_node_id="NewNode", old_node_id="OldNode", + input_mapping=[{"new_id": "a", "old_id": "x"}], + ) + second = FakeNodeReplace( + new_node_id="NewNode", old_node_id="OldNode", + input_mapping=[{"new_id": "b", "old_id": "y"}], + ) + manager.register(first) + manager.register(second) + replacements = manager.get_replacement("OldNode") + assert len(replacements) == 1 + assert replacements[0] is first + + +def test_register_dedupe_does_not_affect_other_old_nodes(NodeReplaceManager): + manager = NodeReplaceManager() + manager.register(FakeNodeReplace(new_node_id="NewA", old_node_id="OldA")) + manager.register(FakeNodeReplace(new_node_id="NewA", old_node_id="OldA")) + manager.register(FakeNodeReplace(new_node_id="NewB", old_node_id="OldB")) + assert len(manager.get_replacement("OldA")) == 1 + assert len(manager.get_replacement("OldB")) == 1 From 594de378fe1d2e32128338f5cc57864ee1d9d96f Mon Sep 17 00:00:00 2001 From: Alexis Rolland Date: Fri, 8 May 2026 13:02:55 +0800 Subject: [PATCH 41/56] Update nodes categories and display names (CORE-89) (#13786) --- comfy_extras/nodes_advanced_samplers.py | 2 +- comfy_extras/nodes_attention_multiply.py | 8 ++++---- comfy_extras/nodes_audio_encoder.py | 1 + comfy_extras/nodes_camera_trajectory.py | 2 +- comfy_extras/nodes_cond.py | 4 ++-- comfy_extras/nodes_context_windows.py | 2 +- comfy_extras/nodes_custom_sampler.py | 4 ++-- comfy_extras/nodes_differential_diffusion.py | 2 +- comfy_extras/nodes_fresca.py | 2 +- comfy_extras/nodes_hunyuan.py | 4 ++++ comfy_extras/nodes_hunyuan3d.py | 6 ++++-- comfy_extras/nodes_hypernetwork.py | 1 + comfy_extras/nodes_lora_extract.py | 2 +- comfy_extras/nodes_lt.py | 3 ++- comfy_extras/nodes_mahiro.py | 2 +- comfy_extras/nodes_math.py | 2 +- comfy_extras/nodes_number_convert.py | 2 +- comfy_extras/nodes_perpneg.py | 7 ++++--- comfy_extras/nodes_photomaker.py | 4 ++-- comfy_extras/nodes_post_processing.py | 4 +++- comfy_extras/nodes_rtdetr.py | 4 ++-- comfy_extras/nodes_sag.py | 2 +- comfy_extras/nodes_sam3.py | 8 ++++---- comfy_extras/nodes_stable_cascade.py | 2 +- comfy_extras/nodes_textgen.py | 4 +++- comfy_extras/nodes_torch_compile.py | 2 +- comfy_extras/nodes_train.py | 2 +- comfy_extras/nodes_video_model.py | 2 +- custom_nodes/websocket_image_save.py | 6 +++++- nodes.py | 14 +++++++------ .../testing-pack/api_test_nodes.py | 4 ++-- .../testing-pack/async_test_nodes.py | 20 +++++++++---------- .../testing-pack/specific_tests.py | 6 +++--- 33 files changed, 80 insertions(+), 60 deletions(-) diff --git a/comfy_extras/nodes_advanced_samplers.py b/comfy_extras/nodes_advanced_samplers.py index 7f716cd76..7e8411fa4 100644 --- a/comfy_extras/nodes_advanced_samplers.py +++ b/comfy_extras/nodes_advanced_samplers.py @@ -92,7 +92,7 @@ class SamplerEulerCFGpp(io.ComfyNode): return io.Schema( node_id="SamplerEulerCFGpp", display_name="SamplerEulerCFG++", - category="_for_testing", # "sampling/custom_sampling/samplers" + category="experimental", # "sampling/custom_sampling/samplers" inputs=[ io.Combo.Input("version", options=["regular", "alternative"], advanced=True), ], diff --git a/comfy_extras/nodes_attention_multiply.py b/comfy_extras/nodes_attention_multiply.py index 060a5c9be..f4ee6a689 100644 --- a/comfy_extras/nodes_attention_multiply.py +++ b/comfy_extras/nodes_attention_multiply.py @@ -25,7 +25,7 @@ class UNetSelfAttentionMultiply(io.ComfyNode): def define_schema(cls) -> io.Schema: return io.Schema( node_id="UNetSelfAttentionMultiply", - category="_for_testing/attention_experiments", + category="experimental/attention_experiments", inputs=[ io.Model.Input("model"), io.Float.Input("q", default=1.0, min=0.0, max=10.0, step=0.01, advanced=True), @@ -48,7 +48,7 @@ class UNetCrossAttentionMultiply(io.ComfyNode): def define_schema(cls) -> io.Schema: return io.Schema( node_id="UNetCrossAttentionMultiply", - category="_for_testing/attention_experiments", + category="experimental/attention_experiments", inputs=[ io.Model.Input("model"), io.Float.Input("q", default=1.0, min=0.0, max=10.0, step=0.01, advanced=True), @@ -72,7 +72,7 @@ class CLIPAttentionMultiply(io.ComfyNode): return io.Schema( node_id="CLIPAttentionMultiply", search_aliases=["clip attention scale", "text encoder attention"], - category="_for_testing/attention_experiments", + category="experimental/attention_experiments", inputs=[ io.Clip.Input("clip"), io.Float.Input("q", default=1.0, min=0.0, max=10.0, step=0.01, advanced=True), @@ -106,7 +106,7 @@ class UNetTemporalAttentionMultiply(io.ComfyNode): def define_schema(cls) -> io.Schema: return io.Schema( node_id="UNetTemporalAttentionMultiply", - category="_for_testing/attention_experiments", + category="experimental/attention_experiments", inputs=[ io.Model.Input("model"), io.Float.Input("self_structural", default=1.0, min=0.0, max=10.0, step=0.01, advanced=True), diff --git a/comfy_extras/nodes_audio_encoder.py b/comfy_extras/nodes_audio_encoder.py index 13aacd41a..6a85da89b 100644 --- a/comfy_extras/nodes_audio_encoder.py +++ b/comfy_extras/nodes_audio_encoder.py @@ -10,6 +10,7 @@ class AudioEncoderLoader(io.ComfyNode): def define_schema(cls) -> io.Schema: return io.Schema( node_id="AudioEncoderLoader", + display_name="Load Audio Encoder", category="loaders", inputs=[ io.Combo.Input( diff --git a/comfy_extras/nodes_camera_trajectory.py b/comfy_extras/nodes_camera_trajectory.py index e7efa29ba..34b78e81b 100644 --- a/comfy_extras/nodes_camera_trajectory.py +++ b/comfy_extras/nodes_camera_trajectory.py @@ -153,7 +153,7 @@ class WanCameraEmbedding(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="WanCameraEmbedding", - category="camera", + category="conditioning/video_models", inputs=[ io.Combo.Input( "camera_pose", diff --git a/comfy_extras/nodes_cond.py b/comfy_extras/nodes_cond.py index 86426a780..b745a43af 100644 --- a/comfy_extras/nodes_cond.py +++ b/comfy_extras/nodes_cond.py @@ -8,7 +8,7 @@ class CLIPTextEncodeControlnet(io.ComfyNode): def define_schema(cls) -> io.Schema: return io.Schema( node_id="CLIPTextEncodeControlnet", - category="_for_testing/conditioning", + category="experimental/conditioning", inputs=[ io.Clip.Input("clip"), io.Conditioning.Input("conditioning"), @@ -35,7 +35,7 @@ class T5TokenizerOptions(io.ComfyNode): def define_schema(cls) -> io.Schema: return io.Schema( node_id="T5TokenizerOptions", - category="_for_testing/conditioning", + category="experimental/conditioning", inputs=[ io.Clip.Input("clip"), io.Int.Input("min_padding", default=0, min=0, max=10000, step=1, advanced=True), diff --git a/comfy_extras/nodes_context_windows.py b/comfy_extras/nodes_context_windows.py index fefc56d26..f7ca833dc 100644 --- a/comfy_extras/nodes_context_windows.py +++ b/comfy_extras/nodes_context_windows.py @@ -10,7 +10,7 @@ class ContextWindowsManualNode(io.ComfyNode): return io.Schema( node_id="ContextWindowsManual", display_name="Context Windows (Manual)", - category="context", + category="model_patches", description="Manually set context windows.", inputs=[ io.Model.Input("model", tooltip="The model to apply context windows to during sampling."), diff --git a/comfy_extras/nodes_custom_sampler.py b/comfy_extras/nodes_custom_sampler.py index 1e957c09b..c67145d2d 100644 --- a/comfy_extras/nodes_custom_sampler.py +++ b/comfy_extras/nodes_custom_sampler.py @@ -984,7 +984,7 @@ class AddNoise(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="AddNoise", - category="_for_testing/custom_sampling/noise", + category="experimental/custom_sampling/noise", is_experimental=True, inputs=[ io.Model.Input("model"), @@ -1034,7 +1034,7 @@ class ManualSigmas(io.ComfyNode): return io.Schema( node_id="ManualSigmas", search_aliases=["custom noise schedule", "define sigmas"], - category="_for_testing/custom_sampling", + category="experimental/custom_sampling", is_experimental=True, inputs=[ io.String.Input("sigmas", default="1, 0.5", multiline=False) diff --git a/comfy_extras/nodes_differential_diffusion.py b/comfy_extras/nodes_differential_diffusion.py index 34ffb9a89..4fa61ad0e 100644 --- a/comfy_extras/nodes_differential_diffusion.py +++ b/comfy_extras/nodes_differential_diffusion.py @@ -13,7 +13,7 @@ class DifferentialDiffusion(io.ComfyNode): node_id="DifferentialDiffusion", search_aliases=["inpaint gradient", "variable denoise strength"], display_name="Differential Diffusion", - category="_for_testing", + category="experimental", inputs=[ io.Model.Input("model"), io.Float.Input( diff --git a/comfy_extras/nodes_fresca.py b/comfy_extras/nodes_fresca.py index eab4f303f..173f42154 100644 --- a/comfy_extras/nodes_fresca.py +++ b/comfy_extras/nodes_fresca.py @@ -60,7 +60,7 @@ class FreSca(io.ComfyNode): node_id="FreSca", search_aliases=["frequency guidance"], display_name="FreSca", - category="_for_testing", + category="experimental", description="Applies frequency-dependent scaling to the guidance", inputs=[ io.Model.Input("model"), diff --git a/comfy_extras/nodes_hunyuan.py b/comfy_extras/nodes_hunyuan.py index 4ea93a499..9e4873be5 100644 --- a/comfy_extras/nodes_hunyuan.py +++ b/comfy_extras/nodes_hunyuan.py @@ -131,6 +131,8 @@ class HunyuanVideo15SuperResolution(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="HunyuanVideo15SuperResolution", + display_name="Hunyuan Video 1.5 Super Resolution", + category="conditioning/video_models", inputs=[ io.Conditioning.Input("positive"), io.Conditioning.Input("negative"), @@ -381,6 +383,8 @@ class HunyuanRefinerLatent(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="HunyuanRefinerLatent", + display_name="Hunyuan Latent Refiner", + category="conditioning/video_models", inputs=[ io.Conditioning.Input("positive"), io.Conditioning.Input("negative"), diff --git a/comfy_extras/nodes_hunyuan3d.py b/comfy_extras/nodes_hunyuan3d.py index fa55ead59..bf18ecb88 100644 --- a/comfy_extras/nodes_hunyuan3d.py +++ b/comfy_extras/nodes_hunyuan3d.py @@ -40,7 +40,7 @@ class Hunyuan3Dv2Conditioning(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="Hunyuan3Dv2Conditioning", - category="conditioning/video_models", + category="conditioning/3d_models", inputs=[ IO.ClipVisionOutput.Input("clip_vision_output"), ], @@ -65,7 +65,7 @@ class Hunyuan3Dv2ConditioningMultiView(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="Hunyuan3Dv2ConditioningMultiView", - category="conditioning/video_models", + category="conditioning/3d_models", inputs=[ IO.ClipVisionOutput.Input("front", optional=True), IO.ClipVisionOutput.Input("left", optional=True), @@ -424,6 +424,7 @@ class VoxelToMeshBasic(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="VoxelToMeshBasic", + display_name="Voxel to Mesh (Basic)", category="3d", inputs=[ IO.Voxel.Input("voxel"), @@ -453,6 +454,7 @@ class VoxelToMesh(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="VoxelToMesh", + display_name="Voxel to Mesh", category="3d", inputs=[ IO.Voxel.Input("voxel"), diff --git a/comfy_extras/nodes_hypernetwork.py b/comfy_extras/nodes_hypernetwork.py index 2a6a87a81..44a9c6f97 100644 --- a/comfy_extras/nodes_hypernetwork.py +++ b/comfy_extras/nodes_hypernetwork.py @@ -102,6 +102,7 @@ class HypernetworkLoader(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="HypernetworkLoader", + display_name="Load Hypernetwork", category="loaders", inputs=[ IO.Model.Input("model"), diff --git a/comfy_extras/nodes_lora_extract.py b/comfy_extras/nodes_lora_extract.py index 975f90f45..bcd249c29 100644 --- a/comfy_extras/nodes_lora_extract.py +++ b/comfy_extras/nodes_lora_extract.py @@ -91,7 +91,7 @@ class LoraSave(io.ComfyNode): node_id="LoraSave", search_aliases=["export lora"], display_name="Extract and Save Lora", - category="_for_testing", + category="experimental", inputs=[ io.String.Input("filename_prefix", default="loras/ComfyUI_extracted_lora"), io.Int.Input("rank", default=8, min=1, max=4096, step=1, advanced=True), diff --git a/comfy_extras/nodes_lt.py b/comfy_extras/nodes_lt.py index 19d8a387f..ab1359fdb 100644 --- a/comfy_extras/nodes_lt.py +++ b/comfy_extras/nodes_lt.py @@ -594,7 +594,8 @@ class LTXVPreprocess(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="LTXVPreprocess", - category="image", + display_name="LTXV Preprocess", + category="video/preprocessors", inputs=[ io.Image.Input("image"), io.Int.Input( diff --git a/comfy_extras/nodes_mahiro.py b/comfy_extras/nodes_mahiro.py index a25226e6d..7bd5f6652 100644 --- a/comfy_extras/nodes_mahiro.py +++ b/comfy_extras/nodes_mahiro.py @@ -11,7 +11,7 @@ class Mahiro(io.ComfyNode): return io.Schema( node_id="Mahiro", display_name="Positive-Biased Guidance", - category="_for_testing", + category="experimental", description="Modify the guidance to scale more on the 'direction' of the positive prompt rather than the difference between the negative prompt.", inputs=[ io.Model.Input("model"), diff --git a/comfy_extras/nodes_math.py b/comfy_extras/nodes_math.py index 6417bacf1..8f6e687d2 100644 --- a/comfy_extras/nodes_math.py +++ b/comfy_extras/nodes_math.py @@ -70,7 +70,7 @@ class MathExpressionNode(io.ComfyNode): return io.Schema( node_id="ComfyMathExpression", display_name="Math Expression", - category="math", + category="logic", search_aliases=[ "expression", "formula", "calculate", "calculator", "eval", "math", diff --git a/comfy_extras/nodes_number_convert.py b/comfy_extras/nodes_number_convert.py index cac7e736d..ab3f2aa8a 100644 --- a/comfy_extras/nodes_number_convert.py +++ b/comfy_extras/nodes_number_convert.py @@ -21,7 +21,7 @@ class NumberConvertNode(io.ComfyNode): return io.Schema( node_id="ComfyNumberConvert", display_name="Number Convert", - category="math", + category="utils", search_aliases=[ "int to float", "float to int", "number convert", "int2float", "float2int", "cast", "parse number", diff --git a/comfy_extras/nodes_perpneg.py b/comfy_extras/nodes_perpneg.py index ed1467de9..a7a72d1bc 100644 --- a/comfy_extras/nodes_perpneg.py +++ b/comfy_extras/nodes_perpneg.py @@ -24,8 +24,8 @@ class PerpNeg(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="PerpNeg", - display_name="Perp-Neg (DEPRECATED by PerpNegGuider)", - category="_for_testing", + display_name="Perp-Neg (DEPRECATED by Perp-Neg Guider)", + category="experimental", inputs=[ io.Model.Input("model"), io.Conditioning.Input("empty_conditioning"), @@ -127,7 +127,8 @@ class PerpNegGuider(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="PerpNegGuider", - category="_for_testing", + display_name="Perp-Neg Guider", + category="experimental", inputs=[ io.Model.Input("model"), io.Conditioning.Input("positive"), diff --git a/comfy_extras/nodes_photomaker.py b/comfy_extras/nodes_photomaker.py index 228183c07..8a2248572 100644 --- a/comfy_extras/nodes_photomaker.py +++ b/comfy_extras/nodes_photomaker.py @@ -123,7 +123,7 @@ class PhotoMakerLoader(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="PhotoMakerLoader", - category="_for_testing/photomaker", + category="experimental/photomaker", inputs=[ io.Combo.Input("photomaker_model_name", options=folder_paths.get_filename_list("photomaker")), ], @@ -149,7 +149,7 @@ class PhotoMakerEncode(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="PhotoMakerEncode", - category="_for_testing/photomaker", + category="experimental/photomaker", inputs=[ io.Photomaker.Input("photomaker"), io.Image.Input("image"), diff --git a/comfy_extras/nodes_post_processing.py b/comfy_extras/nodes_post_processing.py index d938a2035..1fa14d2d2 100644 --- a/comfy_extras/nodes_post_processing.py +++ b/comfy_extras/nodes_post_processing.py @@ -116,6 +116,7 @@ class Quantize(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="ImageQuantize", + display_name="Quantize Image", category="image/postprocessing", inputs=[ io.Image.Input("image"), @@ -181,6 +182,7 @@ class Sharpen(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="ImageSharpen", + display_name="Sharpen Image", category="image/postprocessing", inputs=[ io.Image.Input("image"), @@ -436,7 +438,7 @@ class ResizeImageMaskNode(io.ComfyNode): node_id="ResizeImageMaskNode", display_name="Resize Image/Mask", description="Resize an image or mask using various scaling methods.", - category="transform", + category="image/transform", search_aliases=["resize", "resize image", "resize mask", "scale", "scale image", "scale mask", "image resize", "change size", "dimensions", "shrink", "enlarge"], inputs=[ io.MatchType.Input("input", template=template), diff --git a/comfy_extras/nodes_rtdetr.py b/comfy_extras/nodes_rtdetr.py index 7feaf3ab3..a321577c7 100644 --- a/comfy_extras/nodes_rtdetr.py +++ b/comfy_extras/nodes_rtdetr.py @@ -15,7 +15,7 @@ class RTDETR_detect(io.ComfyNode): return io.Schema( node_id="RTDETR_detect", display_name="RT-DETR Detect", - category="detection/", + category="detection", search_aliases=["bbox", "bounding box", "object detection", "coco"], inputs=[ io.Model.Input("model", display_name="model"), @@ -71,7 +71,7 @@ class DrawBBoxes(io.ComfyNode): return io.Schema( node_id="DrawBBoxes", display_name="Draw BBoxes", - category="detection/", + category="detection", search_aliases=["bbox", "bounding box", "object detection", "rt_detr", "visualize detections", "coco"], inputs=[ io.Image.Input("image", optional=True), diff --git a/comfy_extras/nodes_sag.py b/comfy_extras/nodes_sag.py index d9c47851c..9dbf1b6f9 100644 --- a/comfy_extras/nodes_sag.py +++ b/comfy_extras/nodes_sag.py @@ -113,7 +113,7 @@ class SelfAttentionGuidance(io.ComfyNode): return io.Schema( node_id="SelfAttentionGuidance", display_name="Self-Attention Guidance", - category="_for_testing", + category="experimental", inputs=[ io.Model.Input("model"), io.Float.Input("scale", default=0.5, min=-2.0, max=5.0, step=0.01), diff --git a/comfy_extras/nodes_sam3.py b/comfy_extras/nodes_sam3.py index c460506bf..4ea9221e9 100644 --- a/comfy_extras/nodes_sam3.py +++ b/comfy_extras/nodes_sam3.py @@ -93,7 +93,7 @@ class SAM3_Detect(io.ComfyNode): return io.Schema( node_id="SAM3_Detect", display_name="SAM3 Detect", - category="detection/", + category="detection", search_aliases=["sam3", "segment anything", "open vocabulary", "text detection", "segment"], inputs=[ io.Model.Input("model", display_name="model"), @@ -265,7 +265,7 @@ class SAM3_VideoTrack(io.ComfyNode): return io.Schema( node_id="SAM3_VideoTrack", display_name="SAM3 Video Track", - category="detection/", + category="detection", search_aliases=["sam3", "video", "track", "propagate"], inputs=[ io.Image.Input("images", display_name="images", tooltip="Video frames as batched images"), @@ -320,7 +320,7 @@ class SAM3_TrackPreview(io.ComfyNode): return io.Schema( node_id="SAM3_TrackPreview", display_name="SAM3 Track Preview", - category="detection/", + category="detection", inputs=[ SAM3TrackData.Input("track_data", display_name="track_data"), io.Image.Input("images", display_name="images", optional=True), @@ -478,7 +478,7 @@ class SAM3_TrackToMask(io.ComfyNode): return io.Schema( node_id="SAM3_TrackToMask", display_name="SAM3 Track to Mask", - category="detection/", + category="detection", inputs=[ SAM3TrackData.Input("track_data", display_name="track_data"), io.String.Input("object_indices", display_name="object_indices", default="", diff --git a/comfy_extras/nodes_stable_cascade.py b/comfy_extras/nodes_stable_cascade.py index 8c1aebca9..0dc6c9fcd 100644 --- a/comfy_extras/nodes_stable_cascade.py +++ b/comfy_extras/nodes_stable_cascade.py @@ -119,7 +119,7 @@ class StableCascade_SuperResolutionControlnet(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="StableCascade_SuperResolutionControlnet", - category="_for_testing/stable_cascade", + category="experimental/stable_cascade", is_experimental=True, inputs=[ io.Image.Input("image"), diff --git a/comfy_extras/nodes_textgen.py b/comfy_extras/nodes_textgen.py index 1661a1011..d52faf815 100644 --- a/comfy_extras/nodes_textgen.py +++ b/comfy_extras/nodes_textgen.py @@ -26,7 +26,8 @@ class TextGenerate(io.ComfyNode): return io.Schema( node_id="TextGenerate", - category="textgen", + display_name="Generate Text", + category="text", search_aliases=["LLM", "gemma"], inputs=[ io.Clip.Input("clip"), @@ -157,6 +158,7 @@ class TextGenerateLTX2Prompt(TextGenerate): parent_schema = super().define_schema() return io.Schema( node_id="TextGenerateLTX2Prompt", + display_name="Generate LTX2 Prompt", category=parent_schema.category, inputs=parent_schema.inputs, outputs=parent_schema.outputs, diff --git a/comfy_extras/nodes_torch_compile.py b/comfy_extras/nodes_torch_compile.py index c9e2e0026..d4506b1a9 100644 --- a/comfy_extras/nodes_torch_compile.py +++ b/comfy_extras/nodes_torch_compile.py @@ -10,7 +10,7 @@ class TorchCompileModel(io.ComfyNode): def define_schema(cls) -> io.Schema: return io.Schema( node_id="TorchCompileModel", - category="_for_testing", + category="experimental", inputs=[ io.Model.Input("model"), io.Combo.Input( diff --git a/comfy_extras/nodes_train.py b/comfy_extras/nodes_train.py index 0616dfc2d..e9871369b 100644 --- a/comfy_extras/nodes_train.py +++ b/comfy_extras/nodes_train.py @@ -1361,7 +1361,7 @@ class SaveLoRA(io.ComfyNode): node_id="SaveLoRA", search_aliases=["export lora"], display_name="Save LoRA Weights", - category="loaders", + category="advanced/model_merging", is_experimental=True, is_output_node=True, inputs=[ diff --git a/comfy_extras/nodes_video_model.py b/comfy_extras/nodes_video_model.py index bf98e6b82..0f3881a24 100644 --- a/comfy_extras/nodes_video_model.py +++ b/comfy_extras/nodes_video_model.py @@ -15,7 +15,7 @@ class ImageOnlyCheckpointLoader: RETURN_TYPES = ("MODEL", "CLIP_VISION", "VAE") FUNCTION = "load_checkpoint" - CATEGORY = "loaders/video_models" + CATEGORY = "loaders" def load_checkpoint(self, ckpt_name, output_vae=True, output_clip=True): ckpt_path = folder_paths.get_full_path_or_raise("checkpoints", ckpt_name) diff --git a/custom_nodes/websocket_image_save.py b/custom_nodes/websocket_image_save.py index 15f87f9f5..6a8646d0e 100644 --- a/custom_nodes/websocket_image_save.py +++ b/custom_nodes/websocket_image_save.py @@ -22,7 +22,7 @@ class SaveImageWebsocket: OUTPUT_NODE = True - CATEGORY = "api/image" + CATEGORY = "image" def save_images(self, images): pbar = comfy.utils.ProgressBar(images.shape[0]) @@ -42,3 +42,7 @@ class SaveImageWebsocket: NODE_CLASS_MAPPINGS = { "SaveImageWebsocket": SaveImageWebsocket, } + +NODE_DISPLAY_NAME_MAPPINGS = { + "SaveImageWebsocket": "Save Image (Websocket)", +} \ No newline at end of file diff --git a/nodes.py b/nodes.py index ad0cbc675..ae9e70cb9 100644 --- a/nodes.py +++ b/nodes.py @@ -330,7 +330,7 @@ class VAEDecodeTiled: RETURN_TYPES = ("IMAGE",) FUNCTION = "decode" - CATEGORY = "_for_testing" + CATEGORY = "experimental" def decode(self, vae, samples, tile_size, overlap=64, temporal_size=64, temporal_overlap=8): if tile_size < overlap * 4: @@ -377,7 +377,7 @@ class VAEEncodeTiled: RETURN_TYPES = ("LATENT",) FUNCTION = "encode" - CATEGORY = "_for_testing" + CATEGORY = "experimental" def encode(self, vae, pixels, tile_size, overlap, temporal_size=64, temporal_overlap=8): t = vae.encode_tiled(pixels, tile_x=tile_size, tile_y=tile_size, overlap=overlap, tile_t=temporal_size, overlap_t=temporal_overlap) @@ -493,7 +493,7 @@ class SaveLatent: OUTPUT_NODE = True - CATEGORY = "_for_testing" + CATEGORY = "experimental" def save(self, samples, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir) @@ -538,7 +538,7 @@ class LoadLatent: files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f)) and f.endswith(".latent")] return {"required": {"latent": [sorted(files), ]}, } - CATEGORY = "_for_testing" + CATEGORY = "experimental" RETURN_TYPES = ("LATENT", ) FUNCTION = "load" @@ -1443,7 +1443,7 @@ class LatentBlend: RETURN_TYPES = ("LATENT",) FUNCTION = "blend" - CATEGORY = "_for_testing" + CATEGORY = "experimental" def blend(self, samples1, samples2, blend_factor:float, blend_mode: str="normal"): @@ -2092,6 +2092,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "StyleModelLoader": "Load Style Model", "CLIPVisionLoader": "Load CLIP Vision", "UNETLoader": "Load Diffusion Model", + "unCLIPCheckpointLoader": "Load unCLIP Checkpoint", + "GLIGENLoader": "Load GLIGEN Model", # Conditioning "CLIPVisionEncode": "CLIP Vision Encode", "StyleModelApply": "Apply Style Model", @@ -2140,7 +2142,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "ImageSharpen": "Sharpen Image", "ImageScaleToTotalPixels": "Scale Image to Total Pixels", "GetImageSize": "Get Image Size", - # _for_testing + # experimental "VAEDecodeTiled": "VAE Decode (Tiled)", "VAEEncodeTiled": "VAE Encode (Tiled)", } diff --git a/tests/execution/testing_nodes/testing-pack/api_test_nodes.py b/tests/execution/testing_nodes/testing-pack/api_test_nodes.py index b2eaae05e..70c2a9e95 100644 --- a/tests/execution/testing_nodes/testing-pack/api_test_nodes.py +++ b/tests/execution/testing_nodes/testing-pack/api_test_nodes.py @@ -21,7 +21,7 @@ class TestAsyncProgressUpdate(ComfyNodeABC): RETURN_TYPES = (IO.ANY,) FUNCTION = "execute" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" async def execute(self, value, sleep_seconds): start = time.time() @@ -51,7 +51,7 @@ class TestSyncProgressUpdate(ComfyNodeABC): RETURN_TYPES = (IO.ANY,) FUNCTION = "execute" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" def execute(self, value, sleep_seconds): start = time.time() diff --git a/tests/execution/testing_nodes/testing-pack/async_test_nodes.py b/tests/execution/testing_nodes/testing-pack/async_test_nodes.py index 547eea6f4..589dabf17 100644 --- a/tests/execution/testing_nodes/testing-pack/async_test_nodes.py +++ b/tests/execution/testing_nodes/testing-pack/async_test_nodes.py @@ -21,7 +21,7 @@ class TestAsyncValidation(ComfyNodeABC): RETURN_TYPES = ("IMAGE",) FUNCTION = "process" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" @classmethod async def VALIDATE_INPUTS(cls, value, threshold): @@ -53,7 +53,7 @@ class TestAsyncError(ComfyNodeABC): RETURN_TYPES = (IO.ANY,) FUNCTION = "error_execution" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" async def error_execution(self, value, error_after): await asyncio.sleep(error_after) @@ -74,7 +74,7 @@ class TestAsyncValidationError(ComfyNodeABC): RETURN_TYPES = ("IMAGE",) FUNCTION = "process" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" @classmethod async def VALIDATE_INPUTS(cls, value, max_value): @@ -105,7 +105,7 @@ class TestAsyncTimeout(ComfyNodeABC): RETURN_TYPES = (IO.ANY,) FUNCTION = "timeout_execution" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" async def timeout_execution(self, value, timeout, operation_time): try: @@ -129,7 +129,7 @@ class TestSyncError(ComfyNodeABC): RETURN_TYPES = (IO.ANY,) FUNCTION = "sync_error" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" def sync_error(self, value): raise RuntimeError("Intentional sync execution error for testing") @@ -150,7 +150,7 @@ class TestAsyncLazyCheck(ComfyNodeABC): RETURN_TYPES = ("IMAGE",) FUNCTION = "process" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" async def check_lazy_status(self, condition, input1, input2): # Simulate async checking (e.g., querying remote service) @@ -184,7 +184,7 @@ class TestDynamicAsyncGeneration(ComfyNodeABC): RETURN_TYPES = ("IMAGE",) FUNCTION = "generate_async_workflow" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" def generate_async_workflow(self, image1, image2, num_async_nodes, sleep_duration): g = GraphBuilder() @@ -229,7 +229,7 @@ class TestAsyncResourceUser(ComfyNodeABC): RETURN_TYPES = (IO.ANY,) FUNCTION = "use_resource" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" async def use_resource(self, value, resource_id, duration): # Check if resource is already in use @@ -265,7 +265,7 @@ class TestAsyncBatchProcessing(ComfyNodeABC): RETURN_TYPES = ("IMAGE",) FUNCTION = "process_batch" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" async def process_batch(self, images, process_time_per_item, unique_id): batch_size = images.shape[0] @@ -305,7 +305,7 @@ class TestAsyncConcurrentLimit(ComfyNodeABC): RETURN_TYPES = (IO.ANY,) FUNCTION = "limited_execution" - CATEGORY = "_for_testing/async" + CATEGORY = "experimental/async" async def limited_execution(self, value, duration, node_id): async with self._semaphore: diff --git a/tests/execution/testing_nodes/testing-pack/specific_tests.py b/tests/execution/testing_nodes/testing-pack/specific_tests.py index 4f8f01ae4..2eb5d520e 100644 --- a/tests/execution/testing_nodes/testing-pack/specific_tests.py +++ b/tests/execution/testing_nodes/testing-pack/specific_tests.py @@ -409,7 +409,7 @@ class TestSleep(ComfyNodeABC): RETURN_TYPES = (IO.ANY,) FUNCTION = "sleep" - CATEGORY = "_for_testing" + CATEGORY = "experimental" async def sleep(self, value, seconds, unique_id): pbar = ProgressBar(seconds, node_id=unique_id) @@ -440,7 +440,7 @@ class TestParallelSleep(ComfyNodeABC): } RETURN_TYPES = ("IMAGE",) FUNCTION = "parallel_sleep" - CATEGORY = "_for_testing" + CATEGORY = "experimental" OUTPUT_NODE = True def parallel_sleep(self, image1, image2, image3, sleep1, sleep2, sleep3, unique_id): @@ -474,7 +474,7 @@ class TestOutputNodeWithSocketOutput: } RETURN_TYPES = ("IMAGE",) FUNCTION = "process" - CATEGORY = "_for_testing" + CATEGORY = "experimental" OUTPUT_NODE = True def process(self, image, value): From 56c74094c7c2ccbcf23f2aca1e4000199934da13 Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Fri, 8 May 2026 09:39:13 +0300 Subject: [PATCH 42/56] [Partner Nodes] use "adaptive" aspect ratio for SD2 nodes (#13800) Signed-off-by: bigcat88 --- comfy_api_nodes/nodes_bytedance.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/comfy_api_nodes/nodes_bytedance.py b/comfy_api_nodes/nodes_bytedance.py index 2f241a775..5f74f4a14 100644 --- a/comfy_api_nodes/nodes_bytedance.py +++ b/comfy_api_nodes/nodes_bytedance.py @@ -1271,7 +1271,7 @@ PRICE_BADGE_VIDEO = IO.PriceBadge( ) -def _seedance2_text_inputs(resolutions: list[str]): +def _seedance2_text_inputs(resolutions: list[str], default_ratio: str = "16:9"): return [ IO.String.Input( "prompt", @@ -1287,6 +1287,7 @@ def _seedance2_text_inputs(resolutions: list[str]): IO.Combo.Input( "ratio", options=["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"], + default=default_ratio, tooltip="Aspect ratio of the output video.", ), IO.Int.Input( @@ -1420,8 +1421,14 @@ class ByteDance2FirstLastFrameNode(IO.ComfyNode): IO.DynamicCombo.Input( "model", options=[ - IO.DynamicCombo.Option("Seedance 2.0", _seedance2_text_inputs(["480p", "720p", "1080p"])), - IO.DynamicCombo.Option("Seedance 2.0 Fast", _seedance2_text_inputs(["480p", "720p"])), + IO.DynamicCombo.Option( + "Seedance 2.0", + _seedance2_text_inputs(["480p", "720p", "1080p"], default_ratio="adaptive"), + ), + IO.DynamicCombo.Option( + "Seedance 2.0 Fast", + _seedance2_text_inputs(["480p", "720p"], default_ratio="adaptive"), + ), ], tooltip="Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization.", ), @@ -1588,9 +1595,9 @@ class ByteDance2FirstLastFrameNode(IO.ComfyNode): return IO.NodeOutput(await download_url_to_video_output(response.content.video_url)) -def _seedance2_reference_inputs(resolutions: list[str]): +def _seedance2_reference_inputs(resolutions: list[str], default_ratio: str = "16:9"): return [ - *_seedance2_text_inputs(resolutions), + *_seedance2_text_inputs(resolutions, default_ratio=default_ratio), IO.Autogrow.Input( "reference_images", template=IO.Autogrow.TemplateNames( @@ -1668,8 +1675,14 @@ class ByteDance2ReferenceNode(IO.ComfyNode): IO.DynamicCombo.Input( "model", options=[ - IO.DynamicCombo.Option("Seedance 2.0", _seedance2_reference_inputs(["480p", "720p", "1080p"])), - IO.DynamicCombo.Option("Seedance 2.0 Fast", _seedance2_reference_inputs(["480p", "720p"])), + IO.DynamicCombo.Option( + "Seedance 2.0", + _seedance2_reference_inputs(["480p", "720p", "1080p"], default_ratio="adaptive"), + ), + IO.DynamicCombo.Option( + "Seedance 2.0 Fast", + _seedance2_reference_inputs(["480p", "720p"], default_ratio="adaptive"), + ), ], tooltip="Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization.", ), From bac6fc35fbf3fb2a6fc7e54fce17203215bcfff5 Mon Sep 17 00:00:00 2001 From: omahs <73983677+omahs@users.noreply.github.com> Date: Fri, 8 May 2026 11:14:45 +0200 Subject: [PATCH 43/56] Fix typos (#10986) --- comfy/hooks.py | 2 +- comfy/ldm/modules/diffusionmodules/util.py | 2 +- comfy_extras/nodes_flux.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/comfy/hooks.py b/comfy/hooks.py index 1a76c7ba4..5458fc3d8 100644 --- a/comfy/hooks.py +++ b/comfy/hooks.py @@ -93,7 +93,7 @@ class Hook: self.hook_scope = hook_scope '''Scope of where this hook should apply in terms of the conds used in sampling run.''' self.custom_should_register = default_should_register - '''Can be overriden with a compatible function to decide if this hook should be registered without the need to override .should_register''' + '''Can be overridden with a compatible function to decide if this hook should be registered without the need to override .should_register''' @property def strength(self): diff --git a/comfy/ldm/modules/diffusionmodules/util.py b/comfy/ldm/modules/diffusionmodules/util.py index 233011dc9..aed5c149c 100644 --- a/comfy/ldm/modules/diffusionmodules/util.py +++ b/comfy/ldm/modules/diffusionmodules/util.py @@ -140,7 +140,7 @@ def make_ddim_sampling_parameters(alphacums, ddim_timesteps, eta, verbose=True): alphas = alphacums[ddim_timesteps] alphas_prev = np.asarray([alphacums[0]] + alphacums[ddim_timesteps[:-1]].tolist()) - # according the the formula provided in https://arxiv.org/abs/2010.02502 + # according to the formula provided in https://arxiv.org/abs/2010.02502 sigmas = eta * np.sqrt((1 - alphas_prev) / (1 - alphas) * (1 - alphas / alphas_prev)) if verbose: logging.info(f'Selected alphas for ddim sampler: a_t: {alphas}; a_(t-1): {alphas_prev}') diff --git a/comfy_extras/nodes_flux.py b/comfy_extras/nodes_flux.py index 3a23c7d04..5e04a5f77 100644 --- a/comfy_extras/nodes_flux.py +++ b/comfy_extras/nodes_flux.py @@ -102,7 +102,7 @@ class FluxDisableGuidance(io.ComfyNode): append = execute # TODO: remove -PREFERED_KONTEXT_RESOLUTIONS = [ +PREFERRED_KONTEXT_RESOLUTIONS = [ (672, 1568), (688, 1504), (720, 1456), @@ -143,7 +143,7 @@ class FluxKontextImageScale(io.ComfyNode): width = image.shape[2] height = image.shape[1] aspect_ratio = width / height - _, width, height = min((abs(aspect_ratio - w / h), w, h) for w, h in PREFERED_KONTEXT_RESOLUTIONS) + _, width, height = min((abs(aspect_ratio - w / h), w, h) for w, h in PREFERRED_KONTEXT_RESOLUTIONS) image = comfy.utils.common_upscale(image.movedim(-1, 1), width, height, "lanczos", "center").movedim(1, -1) return io.NodeOutput(image) From d3c18c163665a6f94e7dc56823aabcb93ebf7e5e Mon Sep 17 00:00:00 2001 From: "Yousef R. Gamaleldin" <81116377+yousef-rafat@users.noreply.github.com> Date: Fri, 8 May 2026 12:59:24 +0300 Subject: [PATCH 44/56] Add support for BiRefNet background remove model (CORE-46) (#12747) --- comfy/background_removal/birefnet.json | 7 + comfy/background_removal/birefnet.py | 689 ++++++++++++++++++ comfy/bg_removal_model.py | 78 ++ comfy/ops.py | 22 + comfy_api/latest/_io.py | 7 + comfy_extras/nodes_bg_removal.py | 60 ++ comfy_extras/nodes_mask.py | 27 +- folder_paths.py | 2 + .../put_background_removal_models_here | 0 nodes.py | 1 + 10 files changed, 887 insertions(+), 6 deletions(-) create mode 100644 comfy/background_removal/birefnet.json create mode 100644 comfy/background_removal/birefnet.py create mode 100644 comfy/bg_removal_model.py create mode 100644 comfy_extras/nodes_bg_removal.py create mode 100644 models/background_removal/put_background_removal_models_here diff --git a/comfy/background_removal/birefnet.json b/comfy/background_removal/birefnet.json new file mode 100644 index 000000000..f0960af39 --- /dev/null +++ b/comfy/background_removal/birefnet.json @@ -0,0 +1,7 @@ +{ + "model_type": "birefnet", + "image_std": [1.0, 1.0, 1.0], + "image_mean": [0.0, 0.0, 0.0], + "image_size": 1024, + "resize_to_original": true +} diff --git a/comfy/background_removal/birefnet.py b/comfy/background_removal/birefnet.py new file mode 100644 index 000000000..df54b2b90 --- /dev/null +++ b/comfy/background_removal/birefnet.py @@ -0,0 +1,689 @@ +import torch +import comfy.ops +import numpy as np +import torch.nn as nn +from functools import partial +import torch.nn.functional as F +from torchvision.ops import deform_conv2d +from comfy.ldm.modules.attention import optimized_attention_for_device + +CXT = [3072, 1536, 768, 384][1:][::-1][-3:] + +class Attention(nn.Module): + def __init__(self, dim, num_heads=8, qkv_bias=False, qk_scale=None, device=None, dtype=None, operations=None): + super().__init__() + + self.dim = dim + self.num_heads = num_heads + head_dim = dim // num_heads + self.scale = qk_scale or head_dim ** -0.5 + + self.q = operations.Linear(dim, dim, bias=qkv_bias, device=device, dtype=dtype) + self.kv = operations.Linear(dim, dim * 2, bias=qkv_bias, device=device, dtype=dtype) + self.proj = operations.Linear(dim, dim, device=device, dtype=dtype) + + def forward(self, x): + B, N, C = x.shape + optimized_attention = optimized_attention_for_device(x.device, mask=False, small_input=True) + q = self.q(x).reshape(B, N, self.num_heads, C // self.num_heads).permute(0, 2, 1, 3) + kv = self.kv(x).reshape(B, -1, 2, self.num_heads, C // self.num_heads).permute(2, 0, 3, 1, 4) + k, v = kv[0], kv[1] + + x = optimized_attention( + q, k, v, heads=self.num_heads, skip_output_reshape=True, skip_reshape=True + ).transpose(1, 2).reshape(B, N, C) + x = self.proj(x) + + return x + +class Mlp(nn.Module): + def __init__(self, in_features, hidden_features=None, out_features=None, device=None, dtype=None, operations=None): + super().__init__() + out_features = out_features or in_features + hidden_features = hidden_features or in_features + self.fc1 = operations.Linear(in_features, hidden_features, device=device, dtype=dtype) + self.act = nn.GELU() + self.fc2 = operations.Linear(hidden_features, out_features, device=device, dtype=dtype) + + def forward(self, x): + x = self.fc1(x) + x = self.act(x) + x = self.fc2(x) + return x + + +def window_partition(x, window_size): + B, H, W, C = x.shape + x = x.view(B, H // window_size, window_size, W // window_size, window_size, C) + windows = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(-1, window_size, window_size, C) + return windows + + +def window_reverse(windows, window_size, H, W): + B = int(windows.shape[0] / (H * W / window_size / window_size)) + x = windows.view(B, H // window_size, W // window_size, window_size, window_size, -1) + x = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(B, H, W, -1) + return x + + +class WindowAttention(nn.Module): + def __init__(self, dim, window_size, num_heads, qkv_bias=True, qk_scale=None, device=None, dtype=None, operations=None): + + super().__init__() + self.dim = dim + self.window_size = window_size # Wh, Ww + self.num_heads = num_heads + head_dim = dim // num_heads + self.scale = qk_scale or head_dim ** -0.5 + + self.relative_position_bias_table = nn.Parameter( + torch.zeros((2 * window_size[0] - 1) * (2 * window_size[1] - 1), num_heads, device=device, dtype=dtype)) + + coords_h = torch.arange(self.window_size[0]) + coords_w = torch.arange(self.window_size[1]) + coords = torch.stack(torch.meshgrid([coords_h, coords_w], indexing='ij')) # 2, Wh, Ww + coords_flatten = torch.flatten(coords, 1) # 2, Wh*Ww + relative_coords = coords_flatten[:, :, None] - coords_flatten[:, None, :] # 2, Wh*Ww, Wh*Ww + relative_coords = relative_coords.permute(1, 2, 0).contiguous() # Wh*Ww, Wh*Ww, 2 + relative_coords[:, :, 0] += self.window_size[0] - 1 + relative_coords[:, :, 1] += self.window_size[1] - 1 + relative_coords[:, :, 0] *= 2 * self.window_size[1] - 1 + relative_position_index = relative_coords.sum(-1) # Wh*Ww, Wh*Ww + self.register_buffer("relative_position_index", relative_position_index) + + self.qkv = operations.Linear(dim, dim * 3, bias=qkv_bias, device=device, dtype=dtype) + self.proj = operations.Linear(dim, dim, device=device, dtype=dtype) + self.softmax = nn.Softmax(dim=-1) + + def forward(self, x, mask=None): + B_, N, C = x.shape + qkv = self.qkv(x).reshape(B_, N, 3, self.num_heads, C // self.num_heads).permute(2, 0, 3, 1, 4) + q, k, v = qkv[0], qkv[1], qkv[2] + + q = q * self.scale + attn = (q @ k.transpose(-2, -1)) + + relative_position_bias = self.relative_position_bias_table[self.relative_position_index.long().view(-1)].view( + self.window_size[0] * self.window_size[1], self.window_size[0] * self.window_size[1], -1) # Wh*Ww,Wh*Ww,nH + relative_position_bias = relative_position_bias.permute(2, 0, 1).contiguous() # nH, Wh*Ww, Wh*Ww + attn = attn + relative_position_bias.unsqueeze(0) + + if mask is not None: + nW = mask.shape[0] + attn = attn.view(B_ // nW, nW, self.num_heads, N, N) + mask.unsqueeze(1).unsqueeze(0) + attn = attn.view(-1, self.num_heads, N, N) + attn = self.softmax(attn) + else: + attn = self.softmax(attn) + + x = (attn @ v).transpose(1, 2).reshape(B_, N, C) + x = self.proj(x) + return x + + +class SwinTransformerBlock(nn.Module): + def __init__(self, dim, num_heads, window_size=7, shift_size=0, + mlp_ratio=4., qkv_bias=True, qk_scale=None, + norm_layer=nn.LayerNorm, device=None, dtype=None, operations=None): + super().__init__() + self.dim = dim + self.num_heads = num_heads + self.window_size = window_size + self.shift_size = shift_size + self.mlp_ratio = mlp_ratio + + self.norm1 = norm_layer(dim, device=device, dtype=dtype) + self.attn = WindowAttention( + dim, window_size=(self.window_size, self.window_size), num_heads=num_heads, + qkv_bias=qkv_bias, qk_scale=qk_scale, device=device, dtype=dtype, operations=operations) + + self.norm2 = norm_layer(dim, device=device, dtype=dtype) + mlp_hidden_dim = int(dim * mlp_ratio) + self.mlp = Mlp(in_features=dim, hidden_features=mlp_hidden_dim, device=device, dtype=dtype, operations=operations) + + self.H = None + self.W = None + + def forward(self, x, mask_matrix): + B, L, C = x.shape + H, W = self.H, self.W + + shortcut = x + x = self.norm1(x) + x = x.view(B, H, W, C) + + pad_l = pad_t = 0 + pad_r = (self.window_size - W % self.window_size) % self.window_size + pad_b = (self.window_size - H % self.window_size) % self.window_size + x = F.pad(x, (0, 0, pad_l, pad_r, pad_t, pad_b)) + _, Hp, Wp, _ = x.shape + + if self.shift_size > 0: + shifted_x = torch.roll(x, shifts=(-self.shift_size, -self.shift_size), dims=(1, 2)) + attn_mask = mask_matrix + else: + shifted_x = x + attn_mask = None + + x_windows = window_partition(shifted_x, self.window_size) + x_windows = x_windows.view(-1, self.window_size * self.window_size, C) + + attn_windows = self.attn(x_windows, mask=attn_mask) + + attn_windows = attn_windows.view(-1, self.window_size, self.window_size, C) + shifted_x = window_reverse(attn_windows, self.window_size, Hp, Wp) # B H' W' C + + if self.shift_size > 0: + x = torch.roll(shifted_x, shifts=(self.shift_size, self.shift_size), dims=(1, 2)) + else: + x = shifted_x + + if pad_r > 0 or pad_b > 0: + x = x[:, :H, :W, :].contiguous() + + x = x.view(B, H * W, C) + + x = shortcut + x + x = x + self.mlp(self.norm2(x)) + + return x + + +class PatchMerging(nn.Module): + def __init__(self, dim, device=None, dtype=None, operations=None): + super().__init__() + self.dim = dim + self.reduction = operations.Linear(4 * dim, 2 * dim, bias=False, device=device, dtype=dtype) + self.norm = operations.LayerNorm(4 * dim, device=device, dtype=dtype) + + def forward(self, x, H, W): + B, L, C = x.shape + x = x.view(B, H, W, C) + + # padding + pad_input = (H % 2 == 1) or (W % 2 == 1) + if pad_input: + x = F.pad(x, (0, 0, 0, W % 2, 0, H % 2)) + + x0 = x[:, 0::2, 0::2, :] # B H/2 W/2 C + x1 = x[:, 1::2, 0::2, :] # B H/2 W/2 C + x2 = x[:, 0::2, 1::2, :] # B H/2 W/2 C + x3 = x[:, 1::2, 1::2, :] # B H/2 W/2 C + x = torch.cat([x0, x1, x2, x3], -1) # B H/2 W/2 4*C + x = x.view(B, -1, 4 * C) # B H/2*W/2 4*C + + x = self.norm(x) + x = self.reduction(x) + + return x + + +class BasicLayer(nn.Module): + def __init__(self, + dim, + depth, + num_heads, + window_size=7, + mlp_ratio=4., + qkv_bias=True, + qk_scale=None, + norm_layer=nn.LayerNorm, + downsample=None, + device=None, dtype=None, operations=None): + super().__init__() + self.window_size = window_size + self.shift_size = window_size // 2 + self.depth = depth + + # build blocks + self.blocks = nn.ModuleList([ + SwinTransformerBlock( + dim=dim, + num_heads=num_heads, + window_size=window_size, + shift_size=0 if (i % 2 == 0) else window_size // 2, + mlp_ratio=mlp_ratio, + qkv_bias=qkv_bias, + qk_scale=qk_scale, + norm_layer=norm_layer, + device=device, dtype=dtype, operations=operations) + for i in range(depth)]) + + # patch merging layer + if downsample is not None: + self.downsample = downsample(dim=dim, device=device, dtype=dtype, operations=operations) + else: + self.downsample = None + + def forward(self, x, H, W): + Hp = int(np.ceil(H / self.window_size)) * self.window_size + Wp = int(np.ceil(W / self.window_size)) * self.window_size + img_mask = torch.zeros((1, Hp, Wp, 1), device=x.device) # 1 Hp Wp 1 + h_slices = (slice(0, -self.window_size), + slice(-self.window_size, -self.shift_size), + slice(-self.shift_size, None)) + w_slices = (slice(0, -self.window_size), + slice(-self.window_size, -self.shift_size), + slice(-self.shift_size, None)) + cnt = 0 + for h in h_slices: + for w in w_slices: + img_mask[:, h, w, :] = cnt + cnt += 1 + + mask_windows = window_partition(img_mask, self.window_size) + mask_windows = mask_windows.view(-1, self.window_size * self.window_size) + attn_mask = mask_windows.unsqueeze(1) - mask_windows.unsqueeze(2) + attn_mask = attn_mask.masked_fill(attn_mask != 0, float(-100.0)).masked_fill(attn_mask == 0, float(0.0)) + + for blk in self.blocks: + blk.H, blk.W = H, W + x = blk(x, attn_mask) + if self.downsample is not None: + x_down = self.downsample(x, H, W) + Wh, Ww = (H + 1) // 2, (W + 1) // 2 + return x, H, W, x_down, Wh, Ww + else: + return x, H, W, x, H, W + + +class PatchEmbed(nn.Module): + def __init__(self, patch_size=4, in_channels=3, embed_dim=96, norm_layer=None, device=None, dtype=None, operations=None): + super().__init__() + patch_size = (patch_size, patch_size) + self.patch_size = patch_size + + self.in_channels = in_channels + self.embed_dim = embed_dim + + self.proj = operations.Conv2d(in_channels, embed_dim, kernel_size=patch_size, stride=patch_size, device=device, dtype=dtype) + if norm_layer is not None: + self.norm = norm_layer(embed_dim, device=device, dtype=dtype) + else: + self.norm = None + + def forward(self, x): + _, _, H, W = x.size() + if W % self.patch_size[1] != 0: + x = F.pad(x, (0, self.patch_size[1] - W % self.patch_size[1])) + if H % self.patch_size[0] != 0: + x = F.pad(x, (0, 0, 0, self.patch_size[0] - H % self.patch_size[0])) + + x = self.proj(x) # B C Wh Ww + if self.norm is not None: + Wh, Ww = x.size(2), x.size(3) + x = x.flatten(2).transpose(1, 2) + x = self.norm(x) + x = x.transpose(1, 2).view(-1, self.embed_dim, Wh, Ww) + + return x + + +class SwinTransformer(nn.Module): + def __init__(self, + pretrain_img_size=224, + patch_size=4, + in_channels=3, + embed_dim=96, + depths=[2, 2, 6, 2], + num_heads=[3, 6, 12, 24], + window_size=7, + mlp_ratio=4., + qkv_bias=True, + qk_scale=None, + patch_norm=True, + out_indices=(0, 1, 2, 3), + frozen_stages=-1, + device=None, dtype=None, operations=None): + super().__init__() + + norm_layer = partial(operations.LayerNorm, device=device, dtype=dtype) + self.pretrain_img_size = pretrain_img_size + self.num_layers = len(depths) + self.embed_dim = embed_dim + self.patch_norm = patch_norm + self.out_indices = out_indices + self.frozen_stages = frozen_stages + + self.patch_embed = PatchEmbed( + patch_size=patch_size, in_channels=in_channels, embed_dim=embed_dim, + device=device, dtype=dtype, operations=operations, + norm_layer=norm_layer if self.patch_norm else None) + + self.layers = nn.ModuleList() + for i_layer in range(self.num_layers): + layer = BasicLayer( + dim=int(embed_dim * 2 ** i_layer), + depth=depths[i_layer], + num_heads=num_heads[i_layer], + window_size=window_size, + mlp_ratio=mlp_ratio, + qkv_bias=qkv_bias, + qk_scale=qk_scale, + norm_layer=norm_layer, + downsample=PatchMerging if (i_layer < self.num_layers - 1) else None, + device=device, dtype=dtype, operations=operations) + self.layers.append(layer) + + num_features = [int(embed_dim * 2 ** i) for i in range(self.num_layers)] + self.num_features = num_features + + for i_layer in out_indices: + layer = norm_layer(num_features[i_layer]) + layer_name = f'norm{i_layer}' + self.add_module(layer_name, layer) + + + def forward(self, x): + x = self.patch_embed(x) + + Wh, Ww = x.size(2), x.size(3) + + outs = [] + x = x.flatten(2).transpose(1, 2) + for i in range(self.num_layers): + layer = self.layers[i] + x_out, H, W, x, Wh, Ww = layer(x, Wh, Ww) + + if i in self.out_indices: + norm_layer = getattr(self, f'norm{i}') + x_out = norm_layer(x_out) + + out = x_out.view(-1, H, W, self.num_features[i]).permute(0, 3, 1, 2).contiguous() + outs.append(out) + + return tuple(outs) + +class DeformableConv2d(nn.Module): + def __init__(self, + in_channels, + out_channels, + kernel_size=3, + stride=1, + padding=1, + bias=False, device=None, dtype=None, operations=None): + + super(DeformableConv2d, self).__init__() + + kernel_size = kernel_size if type(kernel_size) is tuple else (kernel_size, kernel_size) + self.stride = stride if type(stride) is tuple else (stride, stride) + self.padding = padding + + self.offset_conv = operations.Conv2d(in_channels, + 2 * kernel_size[0] * kernel_size[1], + kernel_size=kernel_size, + stride=stride, + padding=self.padding, + bias=True, device=device, dtype=dtype) + + self.modulator_conv = operations.Conv2d(in_channels, + 1 * kernel_size[0] * kernel_size[1], + kernel_size=kernel_size, + stride=stride, + padding=self.padding, + bias=True, device=device, dtype=dtype) + + self.regular_conv = operations.Conv2d(in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + stride=stride, + padding=self.padding, + bias=bias, device=device, dtype=dtype) + + def forward(self, x): + offset = self.offset_conv(x) + modulator = 2. * torch.sigmoid(self.modulator_conv(x)) + weight, bias, offload_info = comfy.ops.cast_bias_weight(self.regular_conv, x, offloadable=True) + + x = deform_conv2d( + input=x, + offset=offset, + weight=weight, + bias=None, + padding=self.padding, + mask=modulator, + stride=self.stride, + ) + comfy.ops.uncast_bias_weight(self.regular_conv, weight, bias, offload_info) + return x + +class BasicDecBlk(nn.Module): + def __init__(self, in_channels=64, out_channels=64, inter_channels=64, device=None, dtype=None, operations=None): + super(BasicDecBlk, self).__init__() + inter_channels = 64 + self.conv_in = operations.Conv2d(in_channels, inter_channels, 3, 1, padding=1, device=device, dtype=dtype) + self.relu_in = nn.ReLU(inplace=True) + self.dec_att = ASPPDeformable(in_channels=inter_channels, device=device, dtype=dtype, operations=operations) + self.conv_out = operations.Conv2d(inter_channels, out_channels, 3, 1, padding=1, device=device, dtype=dtype) + self.bn_in = operations.BatchNorm2d(inter_channels, device=device, dtype=dtype) + self.bn_out = operations.BatchNorm2d(out_channels, device=device, dtype=dtype) + + def forward(self, x): + x = self.conv_in(x) + x = self.bn_in(x) + x = self.relu_in(x) + x = self.dec_att(x) + x = self.conv_out(x) + x = self.bn_out(x) + return x + + +class BasicLatBlk(nn.Module): + def __init__(self, in_channels=64, out_channels=64, device=None, dtype=None, operations=None): + super(BasicLatBlk, self).__init__() + self.conv = operations.Conv2d(in_channels, out_channels, 1, 1, 0, device=device, dtype=dtype) + + def forward(self, x): + x = self.conv(x) + return x + + +class _ASPPModuleDeformable(nn.Module): + def __init__(self, in_channels, planes, kernel_size, padding, device, dtype, operations): + super(_ASPPModuleDeformable, self).__init__() + self.atrous_conv = DeformableConv2d(in_channels, planes, kernel_size=kernel_size, + stride=1, padding=padding, bias=False, device=device, dtype=dtype, operations=operations) + self.bn = operations.BatchNorm2d(planes, device=device, dtype=dtype) + self.relu = nn.ReLU(inplace=True) + + def forward(self, x): + x = self.atrous_conv(x) + x = self.bn(x) + + return self.relu(x) + + +class ASPPDeformable(nn.Module): + def __init__(self, in_channels, out_channels=None, parallel_block_sizes=[1, 3, 7], device=None, dtype=None, operations=None): + super(ASPPDeformable, self).__init__() + self.down_scale = 1 + if out_channels is None: + out_channels = in_channels + self.in_channelster = 256 // self.down_scale + + self.aspp1 = _ASPPModuleDeformable(in_channels, self.in_channelster, 1, padding=0, device=device, dtype=dtype, operations=operations) + self.aspp_deforms = nn.ModuleList([ + _ASPPModuleDeformable(in_channels, self.in_channelster, conv_size, padding=int(conv_size//2), device=device, dtype=dtype, operations=operations) + for conv_size in parallel_block_sizes + ]) + + self.global_avg_pool = nn.Sequential(nn.AdaptiveAvgPool2d((1, 1)), + operations.Conv2d(in_channels, self.in_channelster, 1, stride=1, bias=False, device=device, dtype=dtype), + operations.BatchNorm2d(self.in_channelster, device=device, dtype=dtype), + nn.ReLU(inplace=True)) + self.conv1 = operations.Conv2d(self.in_channelster * (2 + len(self.aspp_deforms)), out_channels, 1, bias=False, device=device, dtype=dtype) + self.bn1 = operations.BatchNorm2d(out_channels, device=device, dtype=dtype) + self.relu = nn.ReLU(inplace=True) + + def forward(self, x): + x1 = self.aspp1(x) + x_aspp_deforms = [aspp_deform(x) for aspp_deform in self.aspp_deforms] + x5 = self.global_avg_pool(x) + x5 = F.interpolate(x5, size=x1.size()[2:], mode='bilinear', align_corners=True) + x = torch.cat((x1, *x_aspp_deforms, x5), dim=1) + + x = self.conv1(x) + x = self.bn1(x) + x = self.relu(x) + + return x + +class BiRefNet(nn.Module): + def __init__(self, config=None, dtype=None, device=None, operations=None): + super(BiRefNet, self).__init__() + self.bb = SwinTransformer(embed_dim=192, depths=[2, 2, 18, 2], num_heads=[6, 12, 24, 48], window_size=12, device=device, dtype=dtype, operations=operations) + + channels = [1536, 768, 384, 192] + channels = [c * 2 for c in channels] + self.cxt = channels[1:][::-1][-3:] + self.squeeze_module = nn.Sequential(*[ + BasicDecBlk(channels[0]+sum(self.cxt), channels[0], device=device, dtype=dtype, operations=operations) + for _ in range(1) + ]) + + self.decoder = Decoder(channels, device=device, dtype=dtype, operations=operations) + + def forward_enc(self, x): + x1, x2, x3, x4 = self.bb(x) + B, C, H, W = x.shape + x1_, x2_, x3_, x4_ = self.bb(F.interpolate(x, size=(H//2, W//2), mode='bilinear', align_corners=True)) + x1 = torch.cat([x1, F.interpolate(x1_, size=x1.shape[2:], mode='bilinear', align_corners=True)], dim=1) + x2 = torch.cat([x2, F.interpolate(x2_, size=x2.shape[2:], mode='bilinear', align_corners=True)], dim=1) + x3 = torch.cat([x3, F.interpolate(x3_, size=x3.shape[2:], mode='bilinear', align_corners=True)], dim=1) + x4 = torch.cat([x4, F.interpolate(x4_, size=x4.shape[2:], mode='bilinear', align_corners=True)], dim=1) + x4 = torch.cat( + ( + *[ + F.interpolate(x1, size=x4.shape[2:], mode='bilinear', align_corners=True), + F.interpolate(x2, size=x4.shape[2:], mode='bilinear', align_corners=True), + F.interpolate(x3, size=x4.shape[2:], mode='bilinear', align_corners=True), + ][-len(CXT):], + x4 + ), + dim=1 + ) + return (x1, x2, x3, x4) + + def forward_ori(self, x): + (x1, x2, x3, x4) = self.forward_enc(x) + x4 = self.squeeze_module(x4) + features = [x, x1, x2, x3, x4] + scaled_preds = self.decoder(features) + return scaled_preds + + def forward(self, pixel_values, intermediate_output=None): + scaled_preds = self.forward_ori(pixel_values) + return scaled_preds + + +class Decoder(nn.Module): + def __init__(self, channels, device, dtype, operations): + super(Decoder, self).__init__() + # factory kwargs + fk = {"device":device, "dtype":dtype, "operations":operations} + DecoderBlock = partial(BasicDecBlk, **fk) + LateralBlock = partial(BasicLatBlk, **fk) + DBlock = partial(SimpleConvs, **fk) + + self.split = True + N_dec_ipt = 64 + ic = 64 + ipt_cha_opt = 1 + self.ipt_blk5 = DBlock(2**10*3 if self.split else 3, [N_dec_ipt, channels[0]//8][ipt_cha_opt], inter_channels=ic) + self.ipt_blk4 = DBlock(2**8*3 if self.split else 3, [N_dec_ipt, channels[0]//8][ipt_cha_opt], inter_channels=ic) + self.ipt_blk3 = DBlock(2**6*3 if self.split else 3, [N_dec_ipt, channels[1]//8][ipt_cha_opt], inter_channels=ic) + self.ipt_blk2 = DBlock(2**4*3 if self.split else 3, [N_dec_ipt, channels[2]//8][ipt_cha_opt], inter_channels=ic) + self.ipt_blk1 = DBlock(2**0*3 if self.split else 3, [N_dec_ipt, channels[3]//8][ipt_cha_opt], inter_channels=ic) + + self.decoder_block4 = DecoderBlock(channels[0]+([N_dec_ipt, channels[0]//8][ipt_cha_opt]), channels[1]) + self.decoder_block3 = DecoderBlock(channels[1]+([N_dec_ipt, channels[0]//8][ipt_cha_opt]), channels[2]) + self.decoder_block2 = DecoderBlock(channels[2]+([N_dec_ipt, channels[1]//8][ipt_cha_opt]), channels[3]) + self.decoder_block1 = DecoderBlock(channels[3]+([N_dec_ipt, channels[2]//8][ipt_cha_opt]), channels[3]//2) + + fk = {"device":device, "dtype":dtype} + + self.conv_out1 = nn.Sequential(operations.Conv2d(channels[3]//2+([N_dec_ipt, channels[3]//8][ipt_cha_opt]), 1, 1, 1, 0, **fk)) + + self.lateral_block4 = LateralBlock(channels[1], channels[1]) + self.lateral_block3 = LateralBlock(channels[2], channels[2]) + self.lateral_block2 = LateralBlock(channels[3], channels[3]) + + self.conv_ms_spvn_4 = operations.Conv2d(channels[1], 1, 1, 1, 0, **fk) + self.conv_ms_spvn_3 = operations.Conv2d(channels[2], 1, 1, 1, 0, **fk) + self.conv_ms_spvn_2 = operations.Conv2d(channels[3], 1, 1, 1, 0, **fk) + + _N = 16 + + self.gdt_convs_4 = nn.Sequential(operations.Conv2d(channels[0] // 2, _N, 3, 1, 1, **fk), operations.BatchNorm2d(_N, **fk), nn.ReLU(inplace=True)) + self.gdt_convs_3 = nn.Sequential(operations.Conv2d(channels[1] // 2, _N, 3, 1, 1, **fk), operations.BatchNorm2d(_N, **fk), nn.ReLU(inplace=True)) + self.gdt_convs_2 = nn.Sequential(operations.Conv2d(channels[2] // 2, _N, 3, 1, 1, **fk), operations.BatchNorm2d(_N, **fk), nn.ReLU(inplace=True)) + + [setattr(self, f"gdt_convs_pred_{i}", nn.Sequential(operations.Conv2d(_N, 1, 1, 1, 0, **fk))) for i in range(2, 5)] + [setattr(self, f"gdt_convs_attn_{i}", nn.Sequential(operations.Conv2d(_N, 1, 1, 1, 0, **fk))) for i in range(2, 5)] + + def get_patches_batch(self, x, p): + _size_h, _size_w = p.shape[2:] + patches_batch = [] + for idx in range(x.shape[0]): + columns_x = torch.split(x[idx], split_size_or_sections=_size_w, dim=-1) + patches_x = [] + for column_x in columns_x: + patches_x += [p.unsqueeze(0) for p in torch.split(column_x, split_size_or_sections=_size_h, dim=-2)] + patch_sample = torch.cat(patches_x, dim=1) + patches_batch.append(patch_sample) + return torch.cat(patches_batch, dim=0) + + def forward(self, features): + x, x1, x2, x3, x4 = features + + patches_batch = self.get_patches_batch(x, x4) if self.split else x + x4 = torch.cat((x4, self.ipt_blk5(F.interpolate(patches_batch, size=x4.shape[2:], mode='bilinear', align_corners=True))), 1) + p4 = self.decoder_block4(x4) + p4_gdt = self.gdt_convs_4(p4) + gdt_attn_4 = self.gdt_convs_attn_4(p4_gdt).sigmoid() + p4 = p4 * gdt_attn_4 + _p4 = F.interpolate(p4, size=x3.shape[2:], mode='bilinear', align_corners=True) + _p3 = _p4 + self.lateral_block4(x3) + + patches_batch = self.get_patches_batch(x, _p3) if self.split else x + _p3 = torch.cat((_p3, self.ipt_blk4(F.interpolate(patches_batch, size=x3.shape[2:], mode='bilinear', align_corners=True))), 1) + p3 = self.decoder_block3(_p3) + + p3_gdt = self.gdt_convs_3(p3) + gdt_attn_3 = self.gdt_convs_attn_3(p3_gdt).sigmoid() + p3 = p3 * gdt_attn_3 + _p3 = F.interpolate(p3, size=x2.shape[2:], mode='bilinear', align_corners=True) + _p2 = _p3 + self.lateral_block3(x2) + + patches_batch = self.get_patches_batch(x, _p2) if self.split else x + _p2 = torch.cat((_p2, self.ipt_blk3(F.interpolate(patches_batch, size=x2.shape[2:], mode='bilinear', align_corners=True))), 1) + p2 = self.decoder_block2(_p2) + + p2_gdt = self.gdt_convs_2(p2) + gdt_attn_2 = self.gdt_convs_attn_2(p2_gdt).sigmoid() + p2 = p2 * gdt_attn_2 + + _p2 = F.interpolate(p2, size=x1.shape[2:], mode='bilinear', align_corners=True) + _p1 = _p2 + self.lateral_block2(x1) + + patches_batch = self.get_patches_batch(x, _p1) if self.split else x + _p1 = torch.cat((_p1, self.ipt_blk2(F.interpolate(patches_batch, size=x1.shape[2:], mode='bilinear', align_corners=True))), 1) + _p1 = self.decoder_block1(_p1) + _p1 = F.interpolate(_p1, size=x.shape[2:], mode='bilinear', align_corners=True) + + patches_batch = self.get_patches_batch(x, _p1) if self.split else x + _p1 = torch.cat((_p1, self.ipt_blk1(F.interpolate(patches_batch, size=x.shape[2:], mode='bilinear', align_corners=True))), 1) + p1_out = self.conv_out1(_p1) + return p1_out + + +class SimpleConvs(nn.Module): + def __init__( + self, in_channels: int, out_channels: int, inter_channels=64, device=None, dtype=None, operations=None + ) -> None: + super().__init__() + self.conv1 = operations.Conv2d(in_channels, inter_channels, 3, 1, 1, device=device, dtype=dtype) + self.conv_out = operations.Conv2d(inter_channels, out_channels, 3, 1, 1, device=device, dtype=dtype) + + def forward(self, x): + return self.conv_out(self.conv1(x)) diff --git a/comfy/bg_removal_model.py b/comfy/bg_removal_model.py new file mode 100644 index 000000000..cb7c2ee53 --- /dev/null +++ b/comfy/bg_removal_model.py @@ -0,0 +1,78 @@ +from .utils import load_torch_file +import os +import json +import torch +import logging + +import comfy.ops +import comfy.model_patcher +import comfy.model_management +import comfy.clip_model +import comfy.background_removal.birefnet + +BG_REMOVAL_MODELS = { + "birefnet": comfy.background_removal.birefnet.BiRefNet +} + +class BackgroundRemovalModel(): + def __init__(self, json_config): + with open(json_config) as f: + config = json.load(f) + + self.image_size = config.get("image_size", 1024) + self.image_mean = config.get("image_mean", [0.0, 0.0, 0.0]) + self.image_std = config.get("image_std", [1.0, 1.0, 1.0]) + self.model_type = config.get("model_type", "birefnet") + self.config = config.copy() + model_class = BG_REMOVAL_MODELS.get(self.model_type) + + self.load_device = comfy.model_management.text_encoder_device() + offload_device = comfy.model_management.text_encoder_offload_device() + self.dtype = comfy.model_management.text_encoder_dtype(self.load_device) + self.model = model_class(config, self.dtype, offload_device, comfy.ops.manual_cast) + self.model.eval() + + self.patcher = comfy.model_patcher.CoreModelPatcher(self.model, load_device=self.load_device, offload_device=offload_device) + + def load_sd(self, sd): + return self.model.load_state_dict(sd, strict=False, assign=self.patcher.is_dynamic()) + + def get_sd(self): + return self.model.state_dict() + + def encode_image(self, image): + comfy.model_management.load_model_gpu(self.patcher) + H, W = image.shape[1], image.shape[2] + pixel_values = comfy.clip_model.clip_preprocess(image.to(self.load_device), size=self.image_size, mean=self.image_mean, std=self.image_std, crop=False) + out = self.model(pixel_values=pixel_values) + out = torch.nn.functional.interpolate(out, size=(H, W), mode="bicubic", antialias=False) + + mask = out.sigmoid() + if mask.ndim == 3: + mask = mask.unsqueeze(0) + if mask.shape[1] != 1: + mask = mask.movedim(-1, 1) + + return mask + + +def load_background_removal_model(sd): + if "bb.layers.1.blocks.0.attn.relative_position_index" in sd: + json_config = os.path.join(os.path.join(os.path.dirname(os.path.realpath(__file__)), "background_removal"), "birefnet.json") + else: + return None + + bg_model = BackgroundRemovalModel(json_config) + m, u = bg_model.load_sd(sd) + if len(m) > 0: + logging.warning("missing background removal: {}".format(m)) + u = set(u) + keys = list(sd.keys()) + for k in keys: + if k not in u: + sd.pop(k) + return bg_model + +def load(ckpt_path): + sd = load_torch_file(ckpt_path) + return load_background_removal_model(sd) diff --git a/comfy/ops.py b/comfy/ops.py index 585c185a3..77ad1d527 100644 --- a/comfy/ops.py +++ b/comfy/ops.py @@ -562,6 +562,25 @@ class disable_weight_init: else: return super().forward(*args, **kwargs) + class BatchNorm2d(torch.nn.BatchNorm2d, CastWeightBiasOp): + def reset_parameters(self): + return None + + def forward_comfy_cast_weights(self, input): + weight, bias, offload_stream = cast_bias_weight(self, input, offloadable=True) + running_mean = self.running_mean.to(device=input.device, dtype=weight.dtype) if self.running_mean is not None else None + running_var = self.running_var.to(device=input.device, dtype=weight.dtype) if self.running_var is not None else None + x = torch.nn.functional.batch_norm(input, running_mean, running_var, weight, bias, self.training, self.momentum, self.eps) + uncast_bias_weight(self, weight, bias, offload_stream) + return x + + def forward(self, *args, **kwargs): + run_every_op() + if self.comfy_cast_weights or len(self.weight_function) > 0 or len(self.bias_function) > 0: + return self.forward_comfy_cast_weights(*args, **kwargs) + else: + return super().forward(*args, **kwargs) + class LayerNorm(torch.nn.LayerNorm, CastWeightBiasOp): def reset_parameters(self): return None @@ -749,6 +768,9 @@ class manual_cast(disable_weight_init): class Conv3d(disable_weight_init.Conv3d): comfy_cast_weights = True + class BatchNorm2d(disable_weight_init.BatchNorm2d): + comfy_cast_weights = True + class GroupNorm(disable_weight_init.GroupNorm): comfy_cast_weights = True diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index e50266bc5..5ed968960 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from spandrel import ImageModelDescriptor from comfy.clip_vision import ClipVisionModel from comfy.clip_vision import Output as ClipVisionOutput_ + from comfy.bg_removal_model import BackgroundRemovalModel from comfy.controlnet import ControlNet from comfy.hooks import HookGroup, HookKeyframeGroup from comfy.model_patcher import ModelPatcher @@ -614,6 +615,11 @@ class Model(ComfyTypeIO): if TYPE_CHECKING: Type = ModelPatcher +@comfytype(io_type="BACKGROUND_REMOVAL") +class BackgroundRemoval(ComfyTypeIO): + if TYPE_CHECKING: + Type = BackgroundRemovalModel + @comfytype(io_type="CLIP_VISION") class ClipVision(ComfyTypeIO): if TYPE_CHECKING: @@ -2257,6 +2263,7 @@ __all__ = [ "ModelPatch", "ClipVision", "ClipVisionOutput", + "BackgroundRemoval", "AudioEncoder", "AudioEncoderOutput", "StyleModel", diff --git a/comfy_extras/nodes_bg_removal.py b/comfy_extras/nodes_bg_removal.py new file mode 100644 index 000000000..8d046b8d4 --- /dev/null +++ b/comfy_extras/nodes_bg_removal.py @@ -0,0 +1,60 @@ +import folder_paths +from typing_extensions import override +from comfy_api.latest import ComfyExtension, IO +from comfy.bg_removal_model import load + + +class LoadBackgroundRemovalModel(IO.ComfyNode): + @classmethod + def define_schema(cls): + files = folder_paths.get_filename_list("background_removal") + return IO.Schema( + node_id="LoadBackgroundRemovalModel", + display_name="Load Background Removal Model", + category="loaders", + inputs=[ + IO.Combo.Input("bg_removal_name", options=sorted(files), tooltip="The model used to remove backgrounds from images"), + ], + outputs=[ + IO.BackgroundRemoval.Output("bg_model") + ] + ) + @classmethod + def execute(cls, bg_removal_name): + path = folder_paths.get_full_path_or_raise("background_removal", bg_removal_name) + bg = load(path) + if bg is None: + raise RuntimeError("ERROR: background model file is invalid and does not contain a valid background removal model.") + return IO.NodeOutput(bg) + +class RemoveBackground(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="RemoveBackground", + display_name="Remove Background", + category="image/background removal", + inputs=[ + IO.Image.Input("image", tooltip="Input image to remove the background from"), + IO.BackgroundRemoval.Input("bg_removal_model", tooltip="Background removal model used to generate the mask") + ], + outputs=[ + IO.Mask.Output("mask", tooltip="Generated foreground mask") + ] + ) + @classmethod + def execute(cls, image, bg_removal_model): + mask = bg_removal_model.encode_image(image) + return IO.NodeOutput(mask) + +class BackgroundRemovalExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [ + LoadBackgroundRemovalModel, + RemoveBackground + ] + + +async def comfy_entrypoint() -> BackgroundRemovalExtension: + return BackgroundRemovalExtension() diff --git a/comfy_extras/nodes_mask.py b/comfy_extras/nodes_mask.py index 43a933dac..c9b2a84d9 100644 --- a/comfy_extras/nodes_mask.py +++ b/comfy_extras/nodes_mask.py @@ -40,10 +40,21 @@ def composite(destination, source, x, y, mask = None, multiplier = 8, resize_sou inverse_mask = torch.ones_like(mask) - mask - source_portion = mask * source[..., :visible_height, :visible_width] - destination_portion = inverse_mask * destination[..., top:bottom, left:right] + source_rgb = source[:, :3, :visible_height, :visible_width] + dest_slice = destination[..., top:bottom, left:right] + + if destination.shape[1] == 4: + if torch.max(dest_slice) == 0: + destination[:, :3, top:bottom, left:right] = source_rgb + destination[:, 3:4, top:bottom, left:right] = mask + else: + destination[:, :3, top:bottom, left:right] = (mask * source_rgb) + (inverse_mask * dest_slice[:, :3]) + destination[:, 3:4, top:bottom, left:right] = torch.max(mask, dest_slice[:, 3:4]) + else: + source_portion = mask * source_rgb + destination_portion = inverse_mask * dest_slice + destination[..., top:bottom, left:right] = source_portion + destination_portion - destination[..., top:bottom, left:right] = source_portion + destination_portion return destination class LatentCompositeMasked(IO.ComfyNode): @@ -84,18 +95,23 @@ class ImageCompositeMasked(IO.ComfyNode): display_name="Image Composite Masked", category="image", inputs=[ - IO.Image.Input("destination"), IO.Image.Input("source"), IO.Int.Input("x", default=0, min=0, max=nodes.MAX_RESOLUTION, step=1), IO.Int.Input("y", default=0, min=0, max=nodes.MAX_RESOLUTION, step=1), IO.Boolean.Input("resize_source", default=False), + IO.Image.Input("destination", optional=True), IO.Mask.Input("mask", optional=True), ], outputs=[IO.Image.Output()], ) @classmethod - def execute(cls, destination, source, x, y, resize_source, mask = None) -> IO.NodeOutput: + def execute(cls, source, x, y, resize_source, destination = None, mask = None) -> IO.NodeOutput: + if destination is None: # transparent rgba + B, H, W, C = source.shape + destination = torch.zeros((B, H, W, 4), dtype=source.dtype, device=source.device) + if C == 3: + source = torch.nn.functional.pad(source, (0, 1), value=1.0) destination, source = node_helpers.image_alpha_fix(destination, source) destination = destination.clone().movedim(-1, 1) output = composite(destination, source.movedim(-1, 1), x, y, mask, 1, resize_source).movedim(1, -1) @@ -381,7 +397,6 @@ class GrowMask(IO.ComfyNode): expand_mask = execute # TODO: remove - class ThresholdMask(IO.ComfyNode): @classmethod def define_schema(cls): diff --git a/folder_paths.py b/folder_paths.py index 98d3b1880..92e8df3cf 100644 --- a/folder_paths.py +++ b/folder_paths.py @@ -52,6 +52,8 @@ folder_names_and_paths["model_patches"] = ([os.path.join(models_dir, "model_patc folder_names_and_paths["audio_encoders"] = ([os.path.join(models_dir, "audio_encoders")], supported_pt_extensions) +folder_names_and_paths["background_removal"] = ([os.path.join(models_dir, "background_removal")], supported_pt_extensions) + folder_names_and_paths["frame_interpolation"] = ([os.path.join(models_dir, "frame_interpolation")], supported_pt_extensions) folder_names_and_paths["optical_flow"] = ([os.path.join(models_dir, "optical_flow")], supported_pt_extensions) diff --git a/models/background_removal/put_background_removal_models_here b/models/background_removal/put_background_removal_models_here new file mode 100644 index 000000000..e69de29bb diff --git a/nodes.py b/nodes.py index ae9e70cb9..5755f0bb8 100644 --- a/nodes.py +++ b/nodes.py @@ -2429,6 +2429,7 @@ async def init_builtin_extra_nodes(): "nodes_number_convert.py", "nodes_painter.py", "nodes_curve.py", + "nodes_bg_removal.py", "nodes_rtdetr.py", "nodes_frame_interpolation.py", "nodes_sam3.py", From 05cd076bc1d9386ec77414c96d1460008f653f7c Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Fri, 8 May 2026 08:48:59 -0600 Subject: [PATCH 45/56] fix: Make LTXVAddGuide center-crop guide images to match other LTXV nodes (#13794) --- comfy_extras/nodes_lt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_lt.py b/comfy_extras/nodes_lt.py index ab1359fdb..f1f4d5319 100644 --- a/comfy_extras/nodes_lt.py +++ b/comfy_extras/nodes_lt.py @@ -236,7 +236,7 @@ class LTXVAddGuide(io.ComfyNode): def encode(cls, vae, latent_width, latent_height, images, scale_factors): time_scale_factor, width_scale_factor, height_scale_factor = scale_factors images = images[:(images.shape[0] - 1) // time_scale_factor * time_scale_factor + 1] - pixels = comfy.utils.common_upscale(images.movedim(-1, 1), latent_width * width_scale_factor, latent_height * height_scale_factor, "bilinear", crop="disabled").movedim(1, -1) + pixels = comfy.utils.common_upscale(images.movedim(-1, 1), latent_width * width_scale_factor, latent_height * height_scale_factor, "bilinear", crop="center").movedim(1, -1) encode_pixels = pixels[:, :, :, :3] t = vae.encode(encode_pixels) return encode_pixels, t From 9864f5ac86778221d730c9626952a1ee15c16994 Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Fri, 8 May 2026 09:02:17 -0600 Subject: [PATCH 46/56] fix: Stop LTXVImgToVideoInplace from mutating input latents and dropping noise_mask (#13793) --- comfy_extras/nodes_lt.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/comfy_extras/nodes_lt.py b/comfy_extras/nodes_lt.py index f1f4d5319..a4c85db77 100644 --- a/comfy_extras/nodes_lt.py +++ b/comfy_extras/nodes_lt.py @@ -106,12 +106,12 @@ class LTXVImgToVideoInplace(io.ComfyNode): if bypass: return (latent,) - samples = latent["samples"] + samples = latent["samples"].clone() _, height_scale_factor, width_scale_factor = ( vae.downscale_index_formula ) - batch, _, latent_frames, latent_height, latent_width = samples.shape + _, _, _, latent_height, latent_width = samples.shape width = latent_width * width_scale_factor height = latent_height * height_scale_factor @@ -124,11 +124,7 @@ class LTXVImgToVideoInplace(io.ComfyNode): samples[:, :, :t.shape[2]] = t - conditioning_latent_frames_mask = torch.ones( - (batch, 1, latent_frames, 1, 1), - dtype=torch.float32, - device=samples.device, - ) + conditioning_latent_frames_mask = get_noise_mask(latent) conditioning_latent_frames_mask[:, :, :t.shape[2]] = 1.0 - strength return io.NodeOutput({"samples": samples, "noise_mask": conditioning_latent_frames_mask}) From c5ecd231a2aa41124ec6a958416d166d7dcb81fb Mon Sep 17 00:00:00 2001 From: Alexis Rolland Date: Fri, 8 May 2026 23:06:29 +0800 Subject: [PATCH 47/56] fix: Fix bug when mask not on same device (CORE-181) (#13801) --- comfy/bg_removal_model.py | 2 +- comfy_extras/nodes_compositing.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/comfy/bg_removal_model.py b/comfy/bg_removal_model.py index cb7c2ee53..7877afd7f 100644 --- a/comfy/bg_removal_model.py +++ b/comfy/bg_removal_model.py @@ -47,7 +47,7 @@ class BackgroundRemovalModel(): out = self.model(pixel_values=pixel_values) out = torch.nn.functional.interpolate(out, size=(H, W), mode="bicubic", antialias=False) - mask = out.sigmoid() + mask = out.sigmoid().to(device=comfy.model_management.intermediate_device(), dtype=comfy.model_management.intermediate_dtype()) if mask.ndim == 3: mask = mask.unsqueeze(0) if mask.shape[1] != 1: diff --git a/comfy_extras/nodes_compositing.py b/comfy_extras/nodes_compositing.py index 5b4423734..720efc629 100644 --- a/comfy_extras/nodes_compositing.py +++ b/comfy_extras/nodes_compositing.py @@ -203,7 +203,7 @@ class JoinImageWithAlpha(io.ComfyNode): @classmethod def execute(cls, image: torch.Tensor, alpha: torch.Tensor) -> io.NodeOutput: batch_size = max(len(image), len(alpha)) - alpha = 1.0 - resize_mask(alpha, image.shape[1:]) + alpha = 1.0 - resize_mask(alpha.to(image), image.shape[1:]) alpha = comfy.utils.repeat_to_batch_size(alpha, batch_size) image = comfy.utils.repeat_to_batch_size(image, batch_size) return io.NodeOutput(torch.cat((image[..., :3], alpha.unsqueeze(-1)), dim=-1)) From 87878f354f4d49446ed81b5ebfb98b12dda37c7c Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 8 May 2026 12:39:16 -0700 Subject: [PATCH 48/56] Add cloud-runtime FE-facing operations to spec (#13734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add cloud-runtime FE-facing operations to openapi.yaml Add ~67 cloud-runtime FE-facing path operations to the core OpenAPI spec, each tagged with x-runtime: [cloud] at the operation level. These operations are served by the cloud runtime; the local runtime returns 404 for all of these paths. Domain groups added: - Jobs / prompts: /api/job/*, /api/jobs/*/cancel, /api/prompt/*, etc. - History v2: /api/history_v2, /api/history_v2/{prompt_id} - Cloud logs: /api/logs - Asset extensions: /api/assets/download, export, import, etc. - Custom nodes: /api/experiment/nodes (cloud install/uninstall) - Hub: /api/hub/profiles, /api/hub/workflows, /api/hub/labels, etc. - Workflows: /api/workflows CRUD, versioning, fork, publish - Auth/session: /api/auth/session, /api/auth/token, /.well-known/jwks.json - Billing: /api/billing/balance, plans, subscribe, topup, etc. - Workspace: /api/workspace/*, /api/workspaces/* - User/settings/misc: /api/user, /api/secrets, /api/feedback, etc. Also adds corresponding cloud-only component schemas (CloudJob, CloudWorkflow, BillingPlan, Workspace, HubProfile, AuthSession, etc.), all tagged with x-runtime: [cloud]. Spectral lint passes under the existing ruleset with zero new warnings. * Add job_id field to Asset schema and deprecate prompt_id (#13736) - Add job_id as a nullable UUID field to the Asset schema - Mark prompt_id as deprecated with note pointing to job_id - No x-runtime tag needed as both runtimes populate the field * Add hash field to Asset schemas and deprecate asset_hash (#13738) - Add 'hash' as a nullable string field to Asset and AssetUpdated schemas - Mark 'asset_hash' as deprecated with a note pointing to 'hash' - AssetCreated inherits 'hash' via allOf from Asset - Spectral lint clean (no new warnings) * Fix method drift on cloud-runtime endpoints Three PUT operations were added that should be PATCH (cloud serves PATCH for partial updates): - /api/workflows/{workflow_id} - /api/workspaces/{id} - /api/workspace/members/{userId} Two POST operations were added that should be GET (cloud serves GET with query params): - /api/assets/remote-metadata (url moves to query param) - /api/files/mask-layers (response shape replaced — operation queries related mask layer filenames, not file uploads) * Add missing cloud-runtime operations and schemas PR review surfaced operations the cloud runtime serves that weren't covered by the initial spec push, plus one path family missed entirely. New methods on existing paths: - /api/auth/session: add POST (create session cookie) and DELETE (logout) - /api/secrets/{id}: add GET (read metadata) and PATCH (update) - /api/hub/profiles: add POST (create profile) - /api/hub/workflows: add POST (publish to hub) - /api/hub/workflows/{share_id}: add DELETE (unpublish) - /api/workspaces/{id}: add DELETE (soft-delete workspace) - /api/workspace/members/{user_id}/api-keys: add DELETE (bulk revoke) - /api/workflows/{workflow_id}/versions: add POST (create new version) - /api/userdata/{file}/publish: add GET (read publish info) New path family: - /api/tasks (GET list) and /api/tasks/{task_id} (GET detail) for the background task framework New component schemas (all tagged x-runtime: [cloud]): CreateSessionResponse, DeleteSessionResponse, UpdateSecretRequest, BulkRevokeAPIKeysResponse, CreateHubProfileRequest, PublishHubWorkflowRequest, HubWorkflowDetail, AssetInfo, CreateWorkflowVersionRequest, WorkflowVersionResponse, WorkflowPublishInfo, TaskEntry, TaskResponse, TasksListResponse. Existing SecretMeta extended with provider and last_used_at fields the cloud runtime actually returns. New tag: task. Spectral lint passes with zero errors. * Add job_id and prompt_id to AssetUpdated schema Mirrors the Asset schema's deprecation pattern: prompt_id is marked deprecated with a description pointing to job_id; job_id is the new preferred field. PUT /api/assets/{id} responses can now carry both fields consistent with the other Asset-returning endpoints. * feat: add width and height fields to Asset schema (#13745) Add nullable integer fields 'width' and 'height' to the Asset schema in openapi.yaml. These expose original image dimensions in pixels for clients that need pre-thumbnail size info. Both fields are null for non-image assets or assets ingested before dimension extraction. Co-authored-by: Matt Miller * Remove /api/job/{job_id} and /api/job/{job_id}/outputs These two paths are not actually served by the cloud runtime — they return 404 with a redirect message pointing callers to the canonical `/api/jobs/{job_id}` (plural). Declaring them with `x-runtime: [cloud]` and a 200 response schema is incorrect. `/api/job/{job_id}/status` stays — it is a real cloud-served endpoint. Also drops the now-orphaned `CloudJob` and `CloudJobOutputs` component schemas. `CloudJobStatus` is retained. --- openapi.yaml | 4716 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 4714 insertions(+), 2 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 29b5f544b..4216c1a6c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -62,6 +62,19 @@ tags: - name: assets description: Asset management (feature-gated behind enable-assets) + - name: auth + description: Authentication and session management (cloud-only) + - name: billing + description: Billing, subscriptions, and payment management (cloud-only) + - name: workspace + description: Workspace and team management (cloud-only) + - name: hub + description: "ComfyUI Hub: profiles, shared workflows, and labels (cloud-only)" + - name: workflows + description: Cloud workflow management and versioning (cloud-only) + - name: task + description: Background task management (cloud-only) + paths: # --------------------------------------------------------------------------- # WebSocket @@ -2056,6 +2069,3449 @@ paths: type: integer description: Number of assets marked as missing + + # =========================================================================== + # Cloud-runtime FE-facing operations + # + # These operations are served by the cloud runtime. The local runtime returns + # 404 for all of these paths. Each operation is tagged x-runtime: [cloud]. + # =========================================================================== + + # --------------------------------------------------------------------------- + # Jobs / prompts (cloud) + # --------------------------------------------------------------------------- + /api/jobs/{job_id}/cancel: + post: + operationId: cancelJob + tags: [queue] + summary: Cancel a running or pending job + description: "[cloud-only] Requests cancellation of a job. If the job is currently executing, execution is interrupted. If it is pending in the queue, it is removed." + x-runtime: [cloud] + parameters: + - name: job_id + in: path + required: true + schema: + type: string + format: uuid + description: The job ID to cancel. + responses: + "200": + description: Cancellation accepted + content: + application/json: + schema: + $ref: "#/components/schemas/CloudJobStatus" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/job/{job_id}/status: + get: + operationId: getCloudJobStatus + tags: [queue] + summary: Get status of a cloud job + description: "[cloud-only] Returns the current execution status of a cloud job." + x-runtime: [cloud] + parameters: + - name: job_id + in: path + required: true + schema: + type: string + format: uuid + description: The job ID to check status for. + responses: + "200": + description: Job status + content: + application/json: + schema: + $ref: "#/components/schemas/CloudJobStatus" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/prompt/{prompt_id}: + get: + operationId: getCloudPrompt + tags: [prompt] + summary: Get a cloud prompt by ID + description: "[cloud-only] Returns the full prompt record for a cloud-executed prompt, including the submitted workflow graph and execution metadata." + x-runtime: [cloud] + parameters: + - name: prompt_id + in: path + required: true + schema: + type: string + format: uuid + description: The prompt ID to fetch. + responses: + "200": + description: Cloud prompt detail + content: + application/json: + schema: + $ref: "#/components/schemas/CloudPrompt" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/history_v2: + get: + operationId: getHistoryV2 + tags: [history] + summary: Get paginated execution history (v2) + description: "[cloud-only] Returns a paginated list of execution history entries in the v2 format, with richer metadata than the legacy history endpoint." + x-runtime: [cloud] + parameters: + - name: limit + in: query + schema: + type: integer + default: 20 + description: Maximum number of results + - name: offset + in: query + schema: + type: integer + default: 0 + description: Pagination offset + - name: status + in: query + schema: + type: string + description: Filter by execution status + responses: + "200": + description: History list + content: + application/json: + schema: + $ref: "#/components/schemas/HistoryV2Response" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/history_v2/{prompt_id}: + get: + operationId: getHistoryV2ByPromptId + tags: [history] + summary: Get v2 history for a specific prompt + description: "[cloud-only] Returns the v2 history entry for a specific prompt execution." + x-runtime: [cloud] + parameters: + - name: prompt_id + in: path + required: true + schema: + type: string + format: uuid + description: The prompt ID to fetch history for. + responses: + "200": + description: History entry + content: + application/json: + schema: + $ref: "#/components/schemas/HistoryV2Entry" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/logs: + get: + operationId: getCloudLogs + tags: [system] + summary: Get cloud execution logs + description: "[cloud-only] Returns execution logs for the authenticated user's cloud jobs." + x-runtime: [cloud] + parameters: + - name: job_id + in: query + schema: + type: string + description: Filter logs by job ID + - name: limit + in: query + schema: + type: integer + default: 100 + description: Maximum number of log entries + - name: offset + in: query + schema: + type: integer + default: 0 + description: Pagination offset + responses: + "200": + description: Log entries + content: + application/json: + schema: + $ref: "#/components/schemas/CloudLogsResponse" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + # --------------------------------------------------------------------------- + # Assets extensions (cloud) + # --------------------------------------------------------------------------- + /api/assets/download: + post: + operationId: downloadAssets + tags: [assets] + summary: Download assets to cloud runtime + description: "[cloud-only] Initiates a download of one or more assets to the cloud runtime environment. Returns a task ID for tracking download progress via WebSocket." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - assets + properties: + assets: + type: array + items: + $ref: "#/components/schemas/AssetDownloadRequest" + description: Assets to download + responses: + "200": + description: Download initiated + content: + application/json: + schema: + type: object + properties: + task_id: + type: string + description: Task ID for tracking progress via WebSocket + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/assets/export: + post: + operationId: exportAssets + tags: [assets] + summary: Export assets as a downloadable archive + description: "[cloud-only] Initiates a bulk export of assets. Returns a task ID for tracking progress via WebSocket. When complete, the export can be downloaded via the exports endpoint." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - asset_ids + properties: + asset_ids: + type: array + items: + type: string + format: uuid + description: IDs of assets to export + export_name: + type: string + description: Name for the export archive + responses: + "200": + description: Export initiated + content: + application/json: + schema: + type: object + properties: + task_id: + type: string + export_name: + type: string + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/assets/exports/{exportName}: + get: + operationId: getAssetExport + tags: [assets] + summary: Download a completed asset export + description: "[cloud-only] Returns the archive file for a completed asset export." + x-runtime: [cloud] + parameters: + - name: exportName + in: path + required: true + schema: + type: string + description: Name of the export to download + responses: + "200": + description: Export archive file + content: + application/zip: + schema: + type: string + format: binary + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/assets/from-workflow: + post: + operationId: createAssetsFromWorkflow + tags: [assets] + summary: Create asset records from a workflow execution + description: "[cloud-only] Registers output files from a workflow execution as assets in the asset database." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - prompt_id + properties: + prompt_id: + type: string + format: uuid + description: Prompt ID whose outputs should be registered as assets + tags: + type: array + items: + type: string + description: Tags to apply to the created assets + responses: + "201": + description: Assets created + content: + application/json: + schema: + type: object + properties: + assets: + type: array + items: + $ref: "#/components/schemas/Asset" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/assets/import: + post: + operationId: importAssets + tags: [assets] + summary: Import assets from external URLs + description: "[cloud-only] Imports one or more assets from external URLs into the cloud asset store." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - imports + properties: + imports: + type: array + items: + $ref: "#/components/schemas/AssetImportRequest" + description: Assets to import + responses: + "200": + description: Import initiated + content: + application/json: + schema: + type: object + properties: + assets: + type: array + items: + $ref: "#/components/schemas/Asset" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/assets/remote-metadata: + get: + operationId: getAssetRemoteMetadata + tags: [assets] + summary: Fetch metadata for a remote asset URL + description: "[cloud-only] Fetches and returns metadata (content type, size, filename) for a remote URL without downloading the full content." + x-runtime: [cloud] + parameters: + - name: url + in: query + required: true + schema: + type: string + format: uri + description: URL to inspect + responses: + "200": + description: Remote metadata + content: + application/json: + schema: + $ref: "#/components/schemas/RemoteAssetMetadata" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + # --------------------------------------------------------------------------- + # Custom nodes / hub (cloud) + # --------------------------------------------------------------------------- + /api/experiment/nodes: + get: + operationId: listCloudNodes + tags: [node] + summary: List installed custom nodes + description: "[cloud-only] Returns the list of custom node packages installed in the cloud runtime." + x-runtime: [cloud] + parameters: + - name: limit + in: query + schema: + type: integer + description: Maximum number of results + - name: offset + in: query + schema: + type: integer + description: Pagination offset + responses: + "200": + description: Custom node list + content: + application/json: + schema: + $ref: "#/components/schemas/CloudNodeList" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + post: + operationId: installCloudNode + tags: [node] + summary: Install a custom node package + description: "[cloud-only] Installs a custom node package in the cloud runtime by ID or repository URL." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: string + description: Node package ID or repository URL + version: + type: string + description: Specific version to install + responses: + "200": + description: Node installed + content: + application/json: + schema: + $ref: "#/components/schemas/CloudNode" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/experiment/nodes/{id}: + get: + operationId: getCloudNode + tags: [node] + summary: Get details of an installed custom node + description: "[cloud-only] Returns details about a specific installed custom node package." + x-runtime: [cloud] + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Custom node package ID + responses: + "200": + description: Node detail + content: + application/json: + schema: + $ref: "#/components/schemas/CloudNode" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + delete: + operationId: uninstallCloudNode + tags: [node] + summary: Uninstall a custom node package + description: "[cloud-only] Removes a custom node package from the cloud runtime." + x-runtime: [cloud] + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Custom node package ID + responses: + "204": + description: Node uninstalled + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/hub/assets/upload-url: + post: + operationId: getHubAssetUploadUrl + tags: [hub] + summary: Get a pre-signed upload URL for a hub asset + description: "[cloud-only] Returns a pre-signed URL that can be used to upload an asset file directly to storage." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - filename + - content_type + properties: + filename: + type: string + description: Name of the file to upload + content_type: + type: string + description: MIME type of the file + size: + type: integer + format: int64 + description: File size in bytes + responses: + "200": + description: Upload URL + content: + application/json: + schema: + type: object + properties: + upload_url: + type: string + format: uri + description: Pre-signed upload URL + asset_url: + type: string + format: uri + description: Public URL after upload completes + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/hub/labels: + get: + operationId: listHubLabels + tags: [hub] + summary: List available hub labels + description: "[cloud-only] Returns the list of labels/categories available for tagging hub content." + x-runtime: [cloud] + responses: + "200": + description: Label list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/HubLabel" + + /api/hub/profiles: + get: + operationId: listHubProfiles + tags: [hub] + summary: List hub user profiles + description: "[cloud-only] Returns a paginated list of public hub user profiles." + x-runtime: [cloud] + parameters: + - name: limit + in: query + schema: + type: integer + description: Maximum number of results + - name: offset + in: query + schema: + type: integer + description: Pagination offset + - name: search + in: query + schema: + type: string + description: Search by username or display name + responses: + "200": + description: Profile list + content: + application/json: + schema: + type: object + properties: + profiles: + type: array + items: + $ref: "#/components/schemas/HubProfile" + total: + type: integer + has_more: + type: boolean + post: + operationId: createHubProfile + tags: [hub] + summary: Create a Hub profile + description: "[cloud-only] Creates a hub profile for the specified workspace. Username is immutable after creation." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateHubProfileRequest" + responses: + "201": + description: Hub profile created + content: + application/json: + schema: + $ref: "#/components/schemas/HubProfile" + "400": + description: Bad request (e.g. invalid username) + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "409": + description: Username already taken or profile already exists + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/hub/profiles/{username}: + get: + operationId: getHubProfile + tags: [hub] + summary: Get a hub profile by username + description: "[cloud-only] Returns the public hub profile for the given username." + x-runtime: [cloud] + parameters: + - name: username + in: path + required: true + schema: + type: string + description: Hub username + responses: + "200": + description: Profile + content: + application/json: + schema: + $ref: "#/components/schemas/HubProfile" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/hub/profiles/check: + get: + operationId: checkHubProfileUsername + tags: [hub] + summary: Check if a hub username is available + description: "[cloud-only] Returns whether the given username is available for registration." + x-runtime: [cloud] + parameters: + - name: username + in: query + required: true + schema: + type: string + description: Username to check + responses: + "200": + description: Availability result + content: + application/json: + schema: + type: object + properties: + available: + type: boolean + username: + type: string + + /api/hub/profiles/me: + get: + operationId: getMyHubProfile + tags: [hub] + summary: Get the authenticated user's hub profile + description: "[cloud-only] Returns the hub profile of the currently authenticated user." + x-runtime: [cloud] + responses: + "200": + description: Profile + content: + application/json: + schema: + $ref: "#/components/schemas/HubProfile" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + put: + operationId: updateMyHubProfile + tags: [hub] + summary: Update the authenticated user's hub profile + description: "[cloud-only] Updates the hub profile of the currently authenticated user." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + display_name: + type: string + bio: + type: string + avatar_url: + type: string + format: uri + links: + type: array + items: + type: string + format: uri + responses: + "200": + description: Updated profile + content: + application/json: + schema: + $ref: "#/components/schemas/HubProfile" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/hub/workflows: + get: + operationId: listHubWorkflows + tags: [hub] + summary: List published hub workflows + description: "[cloud-only] Returns a paginated list of publicly shared workflows on the hub." + x-runtime: [cloud] + parameters: + - name: limit + in: query + schema: + type: integer + description: Maximum number of results + - name: offset + in: query + schema: + type: integer + description: Pagination offset + - name: sort + in: query + schema: + type: string + description: Sort field (e.g. created_at, likes) + - name: order + in: query + schema: + type: string + enum: [asc, desc] + description: Sort direction + - name: search + in: query + schema: + type: string + description: Search by title or description + - name: labels + in: query + schema: + type: string + description: Filter by label IDs (comma-separated) + responses: + "200": + description: Hub workflow list + content: + application/json: + schema: + $ref: "#/components/schemas/HubWorkflowList" + post: + operationId: publishHubWorkflow + tags: [hub] + summary: Publish a workflow to the hub + description: "[cloud-only] Publishes a workflow to the hub with metadata, thumbnail, and sample images." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishHubWorkflowRequest" + responses: + "200": + description: Workflow published to hub + content: + application/json: + schema: + $ref: "#/components/schemas/HubWorkflowDetail" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Workflow or profile not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/hub/workflows/{share_id}: + get: + operationId: getHubWorkflow + tags: [hub] + summary: Get a published hub workflow by share ID + description: "[cloud-only] Returns the full details of a published workflow on the hub." + x-runtime: [cloud] + parameters: + - name: share_id + in: path + required: true + schema: + type: string + description: Workflow share ID + responses: + "200": + description: Hub workflow + content: + application/json: + schema: + $ref: "#/components/schemas/HubWorkflow" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + delete: + operationId: deleteHubWorkflow + tags: [hub] + summary: Unpublish a workflow from the hub + description: "[cloud-only] Removes a workflow from the hub listing." + x-runtime: [cloud] + parameters: + - name: share_id + in: path + required: true + schema: + type: string + description: Workflow share ID + responses: + "204": + description: Successfully unpublished + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Workflow not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/hub/workflows/index: + get: + operationId: getHubWorkflowIndex + tags: [hub] + summary: Get the hub workflow index + description: "[cloud-only] Returns the lightweight index of all hub workflows for client-side search and navigation." + x-runtime: [cloud] + responses: + "200": + description: Workflow index + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/HubWorkflowIndexEntry" + + # --------------------------------------------------------------------------- + # Workflows (cloud) + # --------------------------------------------------------------------------- + /api/workflows: + get: + operationId: listCloudWorkflows + tags: [workflows] + summary: List cloud workflows + description: "[cloud-only] Returns a paginated list of the authenticated user's cloud workflows." + x-runtime: [cloud] + parameters: + - name: limit + in: query + schema: + type: integer + description: Maximum number of results + - name: offset + in: query + schema: + type: integer + description: Pagination offset + - name: sort + in: query + schema: + type: string + description: Sort field + - name: order + in: query + schema: + type: string + enum: [asc, desc] + description: Sort direction + - name: search + in: query + schema: + type: string + description: Search by workflow name + responses: + "200": + description: Workflow list + content: + application/json: + schema: + $ref: "#/components/schemas/CloudWorkflowList" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + post: + operationId: createCloudWorkflow + tags: [workflows] + summary: Create a new cloud workflow + description: "[cloud-only] Creates a new cloud workflow with the provided name and optional initial content." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: Workflow name + description: + type: string + description: Workflow description + content: + type: object + additionalProperties: true + description: Initial workflow graph JSON + responses: + "201": + description: Workflow created + content: + application/json: + schema: + $ref: "#/components/schemas/CloudWorkflow" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workflows/{workflow_id}: + get: + operationId: getCloudWorkflow + tags: [workflows] + summary: Get a cloud workflow by ID + description: "[cloud-only] Returns the metadata for a cloud workflow." + x-runtime: [cloud] + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + format: uuid + description: The workflow ID. + responses: + "200": + description: Workflow detail + content: + application/json: + schema: + $ref: "#/components/schemas/CloudWorkflow" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + patch: + operationId: updateCloudWorkflow + tags: [workflows] + summary: Update a cloud workflow + description: "[cloud-only] Updates the metadata (name, description) of an existing cloud workflow." + x-runtime: [cloud] + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + format: uuid + description: The workflow ID. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + responses: + "200": + description: Workflow updated + content: + application/json: + schema: + $ref: "#/components/schemas/CloudWorkflow" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + delete: + operationId: deleteCloudWorkflow + tags: [workflows] + summary: Delete a cloud workflow + description: "[cloud-only] Deletes a cloud workflow and all its versions." + x-runtime: [cloud] + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + format: uuid + description: The workflow ID. + responses: + "204": + description: Workflow deleted + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workflows/{workflow_id}/content: + get: + operationId: getCloudWorkflowContent + tags: [workflows] + summary: Get the content of a cloud workflow + description: "[cloud-only] Returns the full workflow graph JSON for the latest version of a cloud workflow." + x-runtime: [cloud] + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + format: uuid + description: The workflow ID. + - name: version_id + in: query + schema: + type: string + description: Specific version ID to fetch + responses: + "200": + description: Workflow content + content: + application/json: + schema: + type: object + additionalProperties: true + description: The full workflow graph JSON + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + put: + operationId: updateCloudWorkflowContent + tags: [workflows] + summary: Update the content of a cloud workflow + description: "[cloud-only] Saves new workflow graph JSON as a new version of the cloud workflow." + x-runtime: [cloud] + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + format: uuid + description: The workflow ID. + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + description: The workflow graph JSON to save + responses: + "200": + description: Content updated + content: + application/json: + schema: + $ref: "#/components/schemas/CloudWorkflowVersion" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workflows/{workflow_id}/fork: + post: + operationId: forkCloudWorkflow + tags: [workflows] + summary: Fork a cloud workflow + description: "[cloud-only] Creates a copy of a cloud workflow under the authenticated user's account." + x-runtime: [cloud] + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + format: uuid + description: The workflow ID to fork. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Name for the forked workflow (defaults to original name) + responses: + "201": + description: Forked workflow + content: + application/json: + schema: + $ref: "#/components/schemas/CloudWorkflow" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workflows/{workflow_id}/versions: + get: + operationId: listCloudWorkflowVersions + tags: [workflows] + summary: List versions of a cloud workflow + description: "[cloud-only] Returns the version history of a cloud workflow." + x-runtime: [cloud] + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + format: uuid + description: The workflow ID. + - name: limit + in: query + schema: + type: integer + description: Maximum number of results + - name: offset + in: query + schema: + type: integer + description: Pagination offset + responses: + "200": + description: Version list + content: + application/json: + schema: + type: object + properties: + versions: + type: array + items: + $ref: "#/components/schemas/CloudWorkflowVersion" + total: + type: integer + has_more: + type: boolean + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + post: + operationId: createCloudWorkflowVersion + tags: [workflows] + summary: Create a new cloud workflow version + description: "[cloud-only] Creates a new workflow version with updated workflow JSON. Uses optimistic concurrency via base_version." + x-runtime: [cloud] + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + format: uuid + description: The workflow ID. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateWorkflowVersionRequest" + responses: + "201": + description: Version created + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowVersionResponse" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden — not the workflow owner + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "409": + description: Version conflict — base_version does not match latest + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workflows/published/{share_id}: + get: + operationId: getPublishedWorkflow + tags: [workflows] + summary: Get a published workflow by share ID + description: "[cloud-only] Returns a publicly published cloud workflow by its share identifier." + x-runtime: [cloud] + parameters: + - name: share_id + in: path + required: true + schema: + type: string + description: The workflow share ID. + responses: + "200": + description: Published workflow + content: + application/json: + schema: + $ref: "#/components/schemas/CloudWorkflow" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + # --------------------------------------------------------------------------- + # Auth / session (cloud) + # --------------------------------------------------------------------------- + /api/auth/session: + get: + operationId: getAuthSession + tags: [auth] + summary: Get the current authentication session + description: "[cloud-only] Returns the current session state for the authenticated user, including user identity and active workspace." + x-runtime: [cloud] + responses: + "200": + description: Session info + content: + application/json: + schema: + $ref: "#/components/schemas/AuthSession" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + post: + operationId: createAuthSession + tags: [auth] + summary: Create a session cookie + description: "[cloud-only] Creates a session cookie from the bearer token in the Authorization header. Returns a Set-Cookie header with a secure HttpOnly session cookie. Cookie authentication is not allowed for this endpoint." + x-runtime: [cloud] + responses: + "200": + description: Session created + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSessionResponse" + "400": + description: Bad request — invalid or expired ID token + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + delete: + operationId: deleteAuthSession + tags: [auth] + summary: Delete session cookie (logout) + description: "[cloud-only] Clears the session cookie and optionally revokes the session on the server." + x-runtime: [cloud] + responses: + "200": + description: Session deleted + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteSessionResponse" + + /api/auth/token: + post: + operationId: createAuthToken + tags: [auth] + summary: Exchange credentials for an access token + description: "[cloud-only] Exchanges authentication credentials (e.g. an authorization code) for an access token." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - grant_type + properties: + grant_type: + type: string + enum: [authorization_code, refresh_token] + description: OAuth2 grant type + code: + type: string + description: Authorization code (for authorization_code grant) + refresh_token: + type: string + description: Refresh token (for refresh_token grant) + redirect_uri: + type: string + format: uri + description: Redirect URI used in the authorization request + responses: + "200": + description: Token response + content: + application/json: + schema: + $ref: "#/components/schemas/AuthTokenResponse" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /.well-known/jwks.json: + get: + operationId: getJwks + tags: [auth] + summary: Get JSON Web Key Set + description: "[cloud-only] Returns the JSON Web Key Set (JWKS) used to verify JWTs issued by the cloud authentication service." + x-runtime: [cloud] + responses: + "200": + description: JWKS + content: + application/json: + schema: + $ref: "#/components/schemas/JwksResponse" + + # --------------------------------------------------------------------------- + # Billing (cloud) + # --------------------------------------------------------------------------- + /api/billing/balance: + get: + operationId: getBillingBalance + tags: [billing] + summary: Get current credit balance + description: "[cloud-only] Returns the authenticated user's current credit balance and usage summary." + x-runtime: [cloud] + responses: + "200": + description: Balance info + content: + application/json: + schema: + $ref: "#/components/schemas/BillingBalance" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/billing/events: + get: + operationId: listBillingEvents + tags: [billing] + summary: List billing events + description: "[cloud-only] Returns a paginated list of billing events (charges, credits, refunds) for the authenticated user." + x-runtime: [cloud] + parameters: + - name: limit + in: query + schema: + type: integer + description: Maximum number of results + - name: offset + in: query + schema: + type: integer + description: Pagination offset + - name: type + in: query + schema: + type: string + description: Filter by event type + responses: + "200": + description: Billing events + content: + application/json: + schema: + $ref: "#/components/schemas/BillingEventList" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/billing/ops/{id}: + get: + operationId: getBillingOp + tags: [billing] + summary: Get a billing operation by ID + description: "[cloud-only] Returns details of a specific billing operation." + x-runtime: [cloud] + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The billing operation ID. + responses: + "200": + description: Billing operation + content: + application/json: + schema: + $ref: "#/components/schemas/BillingOp" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/billing/payment-portal: + post: + operationId: createPaymentPortalSession + tags: [billing] + summary: Create a payment portal session + description: "[cloud-only] Creates a Stripe customer portal session for managing payment methods and invoices. Returns a URL to redirect the user to." + x-runtime: [cloud] + responses: + "200": + description: Portal session + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + description: Stripe portal URL + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/billing/plans: + get: + operationId: listBillingPlans + tags: [billing] + summary: List available billing plans + description: "[cloud-only] Returns the list of available subscription plans and their pricing." + x-runtime: [cloud] + responses: + "200": + description: Plan list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BillingPlan" + + /api/billing/preview-subscribe: + post: + operationId: previewSubscription + tags: [billing] + summary: Preview a subscription change + description: "[cloud-only] Returns a preview of what a subscription change would cost, including prorations." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - plan_id + properties: + plan_id: + type: string + description: ID of the plan to preview + responses: + "200": + description: Subscription preview + content: + application/json: + schema: + $ref: "#/components/schemas/SubscriptionPreview" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/billing/status: + get: + operationId: getBillingStatus + tags: [billing] + summary: Get billing status + description: "[cloud-only] Returns the authenticated user's current billing and subscription status." + x-runtime: [cloud] + responses: + "200": + description: Billing status + content: + application/json: + schema: + $ref: "#/components/schemas/BillingStatus" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/billing/subscribe: + post: + operationId: createSubscription + tags: [billing] + summary: Subscribe to a billing plan + description: "[cloud-only] Creates a new subscription to the specified billing plan." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - plan_id + properties: + plan_id: + type: string + description: ID of the plan to subscribe to + payment_method_id: + type: string + description: Stripe payment method ID + responses: + "200": + description: Subscription created + content: + application/json: + schema: + $ref: "#/components/schemas/BillingSubscription" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/billing/subscription/cancel: + post: + operationId: cancelSubscription + tags: [billing] + summary: Cancel the active subscription + description: "[cloud-only] Cancels the authenticated user's active subscription. The subscription remains active until the end of the current billing period." + x-runtime: [cloud] + responses: + "200": + description: Subscription cancelled + content: + application/json: + schema: + $ref: "#/components/schemas/BillingSubscription" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/billing/subscription/resubscribe: + post: + operationId: resubscribe + tags: [billing] + summary: Resubscribe after cancellation + description: "[cloud-only] Reactivates a subscription that was previously cancelled but has not yet expired." + x-runtime: [cloud] + responses: + "200": + description: Subscription reactivated + content: + application/json: + schema: + $ref: "#/components/schemas/BillingSubscription" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/billing/topup: + post: + operationId: topUpCredits + tags: [billing] + summary: Purchase additional credits + description: "[cloud-only] Purchases a one-time credit top-up using the user's payment method on file." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - amount + properties: + amount: + type: integer + description: Number of credits to purchase + responses: + "200": + description: Top-up successful + content: + application/json: + schema: + $ref: "#/components/schemas/BillingBalance" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + # --------------------------------------------------------------------------- + # Workspace (cloud) + # --------------------------------------------------------------------------- + /api/workspace/api-keys: + get: + operationId: listWorkspaceApiKeys + tags: [workspace] + summary: List workspace API keys + description: "[cloud-only] Returns the list of API keys for the current workspace." + x-runtime: [cloud] + responses: + "200": + description: API key list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WorkspaceApiKey" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + post: + operationId: createWorkspaceApiKey + tags: [workspace] + summary: Create a workspace API key + description: "[cloud-only] Creates a new API key for the current workspace." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: Display name for the API key + responses: + "201": + description: API key created + content: + application/json: + schema: + $ref: "#/components/schemas/WorkspaceApiKeyCreated" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workspace/api-keys/{id}: + delete: + operationId: deleteWorkspaceApiKey + tags: [workspace] + summary: Delete a workspace API key + description: "[cloud-only] Revokes and deletes a workspace API key." + x-runtime: [cloud] + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The API key ID. + responses: + "204": + description: API key deleted + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workspace/invites: + get: + operationId: listWorkspaceInvites + tags: [workspace] + summary: List pending workspace invites + description: "[cloud-only] Returns the list of pending invitations for the current workspace." + x-runtime: [cloud] + responses: + "200": + description: Invite list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WorkspaceInvite" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + post: + operationId: createWorkspaceInvite + tags: [workspace] + summary: Invite a user to the workspace + description: "[cloud-only] Creates an invitation for a user to join the current workspace." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - email + properties: + email: + type: string + format: email + description: Email address to invite + role: + type: string + enum: [admin, member] + description: Role to assign + responses: + "201": + description: Invite created + content: + application/json: + schema: + $ref: "#/components/schemas/WorkspaceInvite" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workspace/invites/{inviteId}: + delete: + operationId: deleteWorkspaceInvite + tags: [workspace] + summary: Cancel a workspace invite + description: "[cloud-only] Cancels a pending workspace invitation." + x-runtime: [cloud] + parameters: + - name: inviteId + in: path + required: true + schema: + type: string + description: The invite ID. + responses: + "204": + description: Invite cancelled + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workspace/leave: + post: + operationId: leaveWorkspace + tags: [workspace] + summary: Leave the current workspace + description: "[cloud-only] Removes the authenticated user from the current workspace." + x-runtime: [cloud] + responses: + "204": + description: Left workspace + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workspace/members: + get: + operationId: listWorkspaceMembers + tags: [workspace] + summary: List workspace members + description: "[cloud-only] Returns the list of members in the current workspace." + x-runtime: [cloud] + responses: + "200": + description: Member list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WorkspaceMember" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workspace/members/{user_id}/api-keys: + get: + operationId: listMemberApiKeys + tags: [workspace] + summary: List API keys for a workspace member + description: "[cloud-only] Returns the API keys belonging to a specific workspace member. Requires admin role." + x-runtime: [cloud] + parameters: + - name: user_id + in: path + required: true + schema: + type: string + description: The member's user ID. + responses: + "200": + description: API key list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WorkspaceApiKey" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + delete: + operationId: bulkRevokeMemberApiKeys + tags: [workspace] + summary: Bulk revoke a member's API keys + description: "[cloud-only] Revokes all active API keys for a specific workspace member. Only workspace owners can perform this action." + x-runtime: [cloud] + parameters: + - name: user_id + in: path + required: true + schema: + type: string + minLength: 1 + description: The member's user ID. + responses: + "200": + description: Keys revoked + content: + application/json: + schema: + $ref: "#/components/schemas/BulkRevokeAPIKeysResponse" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden — must be workspace owner + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workspace/members/{userId}: + patch: + operationId: updateWorkspaceMember + tags: [workspace] + summary: Update a workspace member's role + description: "[cloud-only] Updates the role of a workspace member. Requires admin role." + x-runtime: [cloud] + parameters: + - name: userId + in: path + required: true + schema: + type: string + description: The member's user ID. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - role + properties: + role: + type: string + enum: [admin, member] + description: New role to assign + responses: + "200": + description: Member updated + content: + application/json: + schema: + $ref: "#/components/schemas/WorkspaceMember" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + delete: + operationId: removeWorkspaceMember + tags: [workspace] + summary: Remove a member from the workspace + description: "[cloud-only] Removes a member from the current workspace. Requires admin role." + x-runtime: [cloud] + parameters: + - name: userId + in: path + required: true + schema: + type: string + description: The member's user ID. + responses: + "204": + description: Member removed + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workspaces: + get: + operationId: listWorkspaces + tags: [workspace] + summary: List workspaces the user belongs to + description: "[cloud-only] Returns the list of workspaces the authenticated user is a member of." + x-runtime: [cloud] + responses: + "200": + description: Workspace list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Workspace" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + post: + operationId: createWorkspace + tags: [workspace] + summary: Create a new workspace + description: "[cloud-only] Creates a new workspace. The authenticated user becomes the owner." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: Workspace name + responses: + "201": + description: Workspace created + content: + application/json: + schema: + $ref: "#/components/schemas/Workspace" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/workspaces/{id}: + get: + operationId: getWorkspace + tags: [workspace] + summary: Get a workspace by ID + description: "[cloud-only] Returns details of a workspace the user is a member of." + x-runtime: [cloud] + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The workspace ID. + responses: + "200": + description: Workspace detail + content: + application/json: + schema: + $ref: "#/components/schemas/Workspace" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + patch: + operationId: updateWorkspace + tags: [workspace] + summary: Update workspace settings + description: "[cloud-only] Updates the name or settings of a workspace. Requires admin role." + x-runtime: [cloud] + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The workspace ID. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: New workspace name + responses: + "200": + description: Workspace updated + content: + application/json: + schema: + $ref: "#/components/schemas/Workspace" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + delete: + operationId: deleteWorkspace + tags: [workspace] + summary: Delete a workspace + description: "[cloud-only] Soft-deletes a workspace. Requires owner role. Personal workspaces cannot be deleted." + x-runtime: [cloud] + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The workspace ID. + responses: + "204": + description: Workspace deleted + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "403": + description: Forbidden — must be workspace owner + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + # --------------------------------------------------------------------------- + # User / settings / misc (cloud) + # --------------------------------------------------------------------------- + /api/feedback: + post: + operationId: submitFeedback + tags: [user] + summary: Submit user feedback + description: "[cloud-only] Submits feedback from the user about their experience with the cloud runtime." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - message + properties: + message: + type: string + description: Feedback message + rating: + type: integer + minimum: 1 + maximum: 5 + description: Optional satisfaction rating + context: + type: object + additionalProperties: true + description: Additional context metadata + responses: + "200": + description: Feedback submitted + content: + application/json: + schema: + type: object + properties: + id: + type: string + status: + type: string + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/files/mask-layers: + get: + operationId: getMaskLayers + tags: [assets] + summary: Get related mask layer filenames + description: "[cloud-only] Given a mask file (any of the 4 layers), returns all related mask layer filenames. Used by the mask editor to load the paint, mask, and painted layers when reopening a previously edited mask." + x-runtime: [cloud] + parameters: + - name: filename + in: query + required: true + schema: + type: string + description: Hash filename of any mask layer file + responses: + "200": + description: Related mask layers + content: + application/json: + schema: + type: object + properties: + mask: + type: string + description: Filename of the mask layer + nullable: true + paint: + type: string + description: Filename of the paint strokes layer + nullable: true + painted: + type: string + description: Filename of the painted image layer + nullable: true + painted_masked: + type: string + description: Filename of the final composite layer + nullable: true + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: File not found or not a mask file + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/internal/cloud_analytics: + post: + operationId: postCloudAnalytics + tags: [internal] + summary: Post client analytics events + description: "[cloud-only] Receives analytics events from the frontend for processing by the cloud analytics pipeline." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - events + properties: + events: + type: array + items: + type: object + required: + - event_name + properties: + event_name: + type: string + timestamp: + type: string + format: date-time + properties: + type: object + additionalProperties: true + responses: + "200": + description: Events accepted + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/invites/{token}/accept: + post: + operationId: acceptInvite + tags: [workspace] + summary: Accept a workspace invitation + description: "[cloud-only] Accepts a workspace invitation using the invite token. The authenticated user is added to the workspace." + x-runtime: [cloud] + parameters: + - name: token + in: path + required: true + schema: + type: string + description: The invitation token. + responses: + "200": + description: Invite accepted + content: + application/json: + schema: + $ref: "#/components/schemas/Workspace" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/secrets: + get: + operationId: listSecrets + tags: [settings] + summary: List user secrets + description: "[cloud-only] Returns the list of secrets (API keys for third-party services) stored for the authenticated user. Secret values are redacted." + x-runtime: [cloud] + responses: + "200": + description: Secret list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SecretMeta" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + post: + operationId: createSecret + tags: [settings] + summary: Create or update a secret + description: "[cloud-only] Stores a new secret or updates an existing one. Secrets are encrypted at rest." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - value + properties: + name: + type: string + description: Secret name (unique per user) + value: + type: string + description: Secret value + responses: + "201": + description: Secret created + content: + application/json: + schema: + $ref: "#/components/schemas/SecretMeta" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/secrets/{id}: + get: + operationId: getSecret + tags: [settings] + summary: Get secret metadata + description: "[cloud-only] Returns metadata for a specific secret. Does not return the plaintext secret value." + x-runtime: [cloud] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: The secret ID. + responses: + "200": + description: Secret metadata + content: + application/json: + schema: + $ref: "#/components/schemas/SecretMeta" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + patch: + operationId: updateSecret + tags: [settings] + summary: Update a secret + description: "[cloud-only] Updates an existing secret's name and/or value. Both fields are optional; only provided fields are updated." + x-runtime: [cloud] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: The secret ID. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSecretRequest" + responses: + "200": + description: Secret updated + content: + application/json: + schema: + $ref: "#/components/schemas/SecretMeta" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "409": + description: Conflict — a secret with this name already exists + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + delete: + operationId: deleteSecret + tags: [settings] + summary: Delete a secret + description: "[cloud-only] Permanently deletes a stored secret." + x-runtime: [cloud] + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The secret ID. + responses: + "204": + description: Secret deleted + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/user: + get: + operationId: getCloudUser + tags: [user] + summary: Get the authenticated cloud user + description: "[cloud-only] Returns the profile and account information for the currently authenticated user." + x-runtime: [cloud] + responses: + "200": + description: User profile + content: + application/json: + schema: + $ref: "#/components/schemas/CloudUser" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + put: + operationId: updateCloudUser + tags: [user] + summary: Update the authenticated cloud user profile + description: "[cloud-only] Updates the profile information for the currently authenticated user." + x-runtime: [cloud] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + display_name: + type: string + avatar_url: + type: string + format: uri + responses: + "200": + description: Updated profile + content: + application/json: + schema: + $ref: "#/components/schemas/CloudUser" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/userdata/{file}/publish: + get: + operationId: getUserdataFilePublish + tags: [userdata] + summary: Get publish info for a userdata file + description: "[cloud-only] Returns the publish status and share info for a userdata workflow file." + x-runtime: [cloud] + parameters: + - name: file + in: path + required: true + schema: + type: string + description: File path relative to user data directory + responses: + "200": + description: Publish info (publish_time is null if never published) + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowPublishInfo" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Workflow not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + post: + operationId: publishUserdataFile + tags: [userdata] + summary: Publish a userdata file to the cloud + description: "[cloud-only] Makes a userdata file available via a public URL for sharing or embedding." + x-runtime: [cloud] + parameters: + - name: file + in: path + required: true + schema: + type: string + description: File path relative to user data directory + responses: + "200": + description: Published file URL + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + description: Public URL of the published file + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/vhs/queryvideo: + get: + operationId: queryVhsVideo + tags: [view] + summary: Query VHS video metadata + description: "[cloud-only] Returns metadata about a video file processed by the VHS (Video Helper Suite) integration." + x-runtime: [cloud] + parameters: + - name: filename + in: query + required: true + schema: + type: string + description: Video filename + - name: type + in: query + schema: + type: string + enum: [input, output, temp] + description: Directory type + - name: subfolder + in: query + schema: + type: string + description: Subfolder within the directory + responses: + "200": + description: Video metadata + content: + application/json: + schema: + type: object + additionalProperties: true + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/vhs/viewaudio: + get: + operationId: viewVhsAudio + tags: [view] + summary: View or download VHS audio + description: "[cloud-only] Returns audio content from a VHS-processed file." + x-runtime: [cloud] + parameters: + - name: filename + in: query + required: true + schema: + type: string + description: Audio filename + - name: type + in: query + schema: + type: string + enum: [input, output, temp] + description: Directory type + - name: subfolder + in: query + schema: + type: string + description: Subfolder within the directory + responses: + "200": + description: Audio content + content: + audio/*: + schema: + type: string + format: binary + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/vhs/viewvideo: + get: + operationId: viewVhsVideo + tags: [view] + summary: View or download VHS video + description: "[cloud-only] Returns video content from a VHS-processed file." + x-runtime: [cloud] + parameters: + - name: filename + in: query + required: true + schema: + type: string + description: Video filename + - name: type + in: query + schema: + type: string + enum: [input, output, temp] + description: Directory type + - name: subfolder + in: query + schema: + type: string + description: Subfolder within the directory + responses: + "200": + description: Video content + content: + video/*: + schema: + type: string + format: binary + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/viewvideo: + get: + operationId: viewVideo + tags: [view] + summary: View or download a video file + description: "[cloud-only] Serves a video file from the output directory. Used by the frontend video player." + x-runtime: [cloud] + parameters: + - name: filename + in: query + required: true + schema: + type: string + description: Video filename + - name: type + in: query + schema: + type: string + enum: [input, output, temp] + description: Directory type + - name: subfolder + in: query + schema: + type: string + description: Subfolder within the directory + responses: + "200": + description: Video content + content: + video/*: + schema: + type: string + format: binary + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/tasks: + get: + operationId: listTasks + tags: [task] + summary: List background tasks + description: "[cloud-only] Retrieve a paginated list of background tasks for the authenticated user. Supports filtering by task type, status, and creation time." + x-runtime: [cloud] + parameters: + - name: task_name + in: query + schema: + type: string + description: Filter by task type name (exact match). + - name: idempotency_key + in: query + schema: + type: string + description: Filter by idempotency key (exact match). + - name: status + in: query + schema: + type: string + description: Filter by one or more statuses (comma-separated). + - name: created_after + in: query + schema: + type: string + format: date-time + description: Filter tasks created after this timestamp. + - name: created_before + in: query + schema: + type: string + format: date-time + description: Filter tasks created before this timestamp. + - name: sort_order + in: query + schema: + type: string + enum: [asc, desc] + default: desc + description: Sort direction by create_time. + - name: offset + in: query + schema: + type: integer + minimum: 0 + default: 0 + description: Pagination offset (0-based). + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + description: Maximum items per page (1-100). + responses: + "200": + description: Tasks retrieved + content: + application/json: + schema: + $ref: "#/components/schemas/TasksListResponse" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "422": + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + /api/tasks/{task_id}: + get: + operationId: getTask + tags: [task] + summary: Get task details + description: "[cloud-only] Retrieve full details for a specific background task." + x-runtime: [cloud] + parameters: + - name: task_id + in: path + required: true + schema: + type: string + format: uuid + description: Task identifier (UUID). + responses: + "200": + description: Task details + content: + application/json: + schema: + $ref: "#/components/schemas/TaskResponse" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + "404": + description: Task not found + content: + application/json: + schema: + $ref: "#/components/schemas/CloudError" + + components: parameters: ComfyUserHeader: @@ -2823,14 +6279,29 @@ components: name: type: string description: Name of the asset file + hash: + type: string + nullable: true + description: Blake3 content hash of the asset (preferred over asset_hash) + pattern: "^blake3:[a-f0-9]{64}$" asset_hash: type: string - description: Blake3 hash of the asset content + nullable: true + deprecated: true + description: "Deprecated: use `hash` instead. Blake3 hash of the asset content." pattern: "^blake3:[a-f0-9]{64}$" size: type: integer format: int64 description: Size of the asset in bytes + width: + type: integer + nullable: true + description: "Original image width in pixels. Null for non-image assets or assets ingested before dimension extraction." + height: + type: integer + nullable: true + description: "Original image height in pixels. Null for non-image assets or assets ingested before dimension extraction." mime_type: type: string description: MIME type of the asset @@ -2859,7 +6330,14 @@ components: prompt_id: type: string format: uuid - description: ID of the prompt that created this asset + nullable: true + deprecated: true + description: "Deprecated: use job_id instead. ID of the prompt that created this asset." + job_id: + type: string + format: uuid + nullable: true + description: ID of the job that created this asset created_at: type: string format: date-time @@ -2897,8 +6375,16 @@ components: format: uuid name: type: string + hash: + type: string + nullable: true + description: Blake3 content hash of the asset (preferred over asset_hash) + pattern: "^blake3:[a-f0-9]{64}$" asset_hash: type: string + nullable: true + deprecated: true + description: "Deprecated: use `hash` instead. Blake3 hash of the asset content." pattern: "^blake3:[a-f0-9]{64}$" tags: type: array @@ -2909,6 +6395,17 @@ components: user_metadata: type: object additionalProperties: true + prompt_id: + type: string + format: uuid + nullable: true + deprecated: true + description: "Deprecated: use job_id instead. ID of the prompt that created this asset." + job_id: + type: string + format: uuid + nullable: true + description: ID of the job that created this asset updated_at: type: string format: date-time @@ -3365,3 +6862,1218 @@ components: enum: [created, running, completed, failed] error: type: string + + + # ------------------------------------------------------------------- + # Cloud-runtime schemas + # + # These schemas are exclusively referenced by cloud-runtime operations. + # Tagged x-runtime: [cloud]. + # ------------------------------------------------------------------- + CloudError: + type: object + x-runtime: [cloud] + description: "[cloud-only] Standard error response from cloud endpoints." + required: + - error + properties: + error: + type: string + description: Error message + code: + type: string + description: Machine-readable error code + details: + type: object + additionalProperties: true + description: Additional error context + + CloudJobStatus: + type: object + x-runtime: [cloud] + description: "[cloud-only] Status of a cloud job." + required: + - id + - status + properties: + id: + type: string + format: uuid + status: + type: string + enum: [pending, running, completed, failed, cancelled] + progress: + type: number + minimum: 0 + maximum: 1 + description: "Execution progress (0.0 to 1.0)" + started_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + + CloudPrompt: + type: object + x-runtime: [cloud] + description: "[cloud-only] A cloud-executed prompt record." + required: + - id + - status + properties: + id: + type: string + format: uuid + status: + type: string + workflow: + type: object + additionalProperties: true + outputs: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + completed_at: + type: string + format: date-time + nullable: true + + HistoryV2Response: + type: object + x-runtime: [cloud] + description: "[cloud-only] Paginated execution history in v2 format." + required: + - items + - total + - has_more + properties: + items: + type: array + items: + $ref: "#/components/schemas/HistoryV2Entry" + total: + type: integer + has_more: + type: boolean + + HistoryV2Entry: + type: object + x-runtime: [cloud] + description: "[cloud-only] A single execution history entry in v2 format." + required: + - id + - status + properties: + id: + type: string + format: uuid + status: + type: string + workflow: + type: object + additionalProperties: true + outputs: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + started_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + preview_output: + type: object + additionalProperties: true + + CloudLogsResponse: + type: object + x-runtime: [cloud] + description: "[cloud-only] Paginated cloud execution logs." + required: + - entries + properties: + entries: + type: array + items: + type: object + properties: + timestamp: + type: string + format: date-time + level: + type: string + enum: [debug, info, warn, error] + message: + type: string + job_id: + type: string + format: uuid + total: + type: integer + has_more: + type: boolean + + AssetDownloadRequest: + type: object + x-runtime: [cloud] + description: "[cloud-only] A single asset to download to the cloud runtime." + required: + - asset_id + properties: + asset_id: + type: string + format: uuid + description: ID of the asset to download + target_path: + type: string + description: Target path on the runtime filesystem + + AssetImportRequest: + type: object + x-runtime: [cloud] + description: "[cloud-only] A single asset to import from an external URL." + required: + - url + properties: + url: + type: string + format: uri + description: URL of the asset to import + name: + type: string + description: Display name for the imported asset + tags: + type: array + items: + type: string + + RemoteAssetMetadata: + type: object + x-runtime: [cloud] + description: "[cloud-only] Metadata fetched from a remote asset URL." + properties: + content_type: + type: string + description: MIME type of the remote file + content_length: + type: integer + format: int64 + description: Size in bytes + filename: + type: string + description: Suggested filename from Content-Disposition or URL + + CloudNode: + type: object + x-runtime: [cloud] + description: "[cloud-only] An installed custom node package in the cloud runtime." + required: + - id + - name + properties: + id: + type: string + name: + type: string + version: + type: string + description: + type: string + author: + type: string + repository: + type: string + format: uri + installed_at: + type: string + format: date-time + enabled: + type: boolean + + CloudNodeList: + type: object + x-runtime: [cloud] + description: "[cloud-only] Paginated list of installed custom node packages." + required: + - nodes + properties: + nodes: + type: array + items: + $ref: "#/components/schemas/CloudNode" + total: + type: integer + has_more: + type: boolean + + HubLabel: + type: object + x-runtime: [cloud] + description: "[cloud-only] A label/category used for tagging hub content." + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + color: + type: string + description: Hex color code for the label + + HubProfile: + type: object + x-runtime: [cloud] + description: "[cloud-only] A public user profile on the ComfyUI Hub." + required: + - username + properties: + username: + type: string + display_name: + type: string + bio: + type: string + avatar_url: + type: string + format: uri + links: + type: array + items: + type: string + format: uri + workflow_count: + type: integer + created_at: + type: string + format: date-time + + HubWorkflow: + type: object + x-runtime: [cloud] + description: "[cloud-only] A published workflow on the ComfyUI Hub." + required: + - share_id + - name + properties: + share_id: + type: string + name: + type: string + description: + type: string + author: + $ref: "#/components/schemas/HubProfile" + labels: + type: array + items: + $ref: "#/components/schemas/HubLabel" + thumbnail_url: + type: string + format: uri + content: + type: object + additionalProperties: true + description: Workflow graph JSON + likes: + type: integer + views: + type: integer + forks: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + HubWorkflowList: + type: object + x-runtime: [cloud] + description: "[cloud-only] Paginated list of hub workflows." + required: + - workflows + - total + - has_more + properties: + workflows: + type: array + items: + $ref: "#/components/schemas/HubWorkflow" + total: + type: integer + has_more: + type: boolean + + HubWorkflowIndexEntry: + type: object + x-runtime: [cloud] + description: "[cloud-only] Lightweight entry in the hub workflow index for client-side search." + required: + - share_id + - name + properties: + share_id: + type: string + name: + type: string + author_username: + type: string + labels: + type: array + items: + type: string + likes: + type: integer + updated_at: + type: string + format: date-time + + CloudWorkflow: + type: object + x-runtime: [cloud] + description: "[cloud-only] A cloud-managed workflow with version history." + required: + - id + - name + properties: + id: + type: string + format: uuid + name: + type: string + description: + type: string + share_id: + type: string + nullable: true + description: Public share identifier if published + latest_version_id: + type: string + format: uuid + nullable: true + thumbnail_url: + type: string + format: uri + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + CloudWorkflowList: + type: object + x-runtime: [cloud] + description: "[cloud-only] Paginated list of cloud workflows." + required: + - workflows + - total + - has_more + properties: + workflows: + type: array + items: + $ref: "#/components/schemas/CloudWorkflow" + total: + type: integer + has_more: + type: boolean + + CloudWorkflowVersion: + type: object + x-runtime: [cloud] + description: "[cloud-only] A version of a cloud workflow." + required: + - id + - workflow_id + properties: + id: + type: string + format: uuid + workflow_id: + type: string + format: uuid + version_number: + type: integer + created_at: + type: string + format: date-time + + AuthSession: + type: object + x-runtime: [cloud] + description: "[cloud-only] Current authentication session state." + required: + - user + properties: + user: + $ref: "#/components/schemas/CloudUser" + workspace: + $ref: "#/components/schemas/Workspace" + expires_at: + type: string + format: date-time + + AuthTokenResponse: + type: object + x-runtime: [cloud] + description: "[cloud-only] OAuth2 token response." + required: + - access_token + - token_type + properties: + access_token: + type: string + token_type: + type: string + description: Always "Bearer" + expires_in: + type: integer + description: Token lifetime in seconds + refresh_token: + type: string + nullable: true + scope: + type: string + + JwksResponse: + type: object + x-runtime: [cloud] + description: "[cloud-only] JSON Web Key Set for JWT verification." + required: + - keys + properties: + keys: + type: array + items: + type: object + required: + - kty + - kid + - use + properties: + kty: + type: string + description: Key type (e.g. RSA) + kid: + type: string + description: Key ID + use: + type: string + description: Key use (e.g. sig) + alg: + type: string + description: Algorithm (e.g. RS256) + n: + type: string + description: RSA modulus (base64url) + e: + type: string + description: RSA exponent (base64url) + additionalProperties: true + + BillingBalance: + type: object + x-runtime: [cloud] + description: "[cloud-only] Current credit balance and usage summary." + required: + - credits_remaining + properties: + credits_remaining: + type: integer + description: Available credits + credits_used: + type: integer + description: Credits used in current billing period + credits_total: + type: integer + description: Total credits allocated in current period + + BillingEvent: + type: object + x-runtime: [cloud] + description: "[cloud-only] A billing event (charge, credit, refund)." + required: + - id + - type + - amount + - created_at + properties: + id: + type: string + type: + type: string + enum: [charge, credit, refund, topup, subscription] + amount: + type: integer + description: Amount in credits + description: + type: string + job_id: + type: string + format: uuid + nullable: true + created_at: + type: string + format: date-time + + BillingEventList: + type: object + x-runtime: [cloud] + description: "[cloud-only] Paginated list of billing events." + required: + - events + - total + - has_more + properties: + events: + type: array + items: + $ref: "#/components/schemas/BillingEvent" + total: + type: integer + has_more: + type: boolean + + BillingOp: + type: object + x-runtime: [cloud] + description: "[cloud-only] A billing operation record." + required: + - id + - status + properties: + id: + type: string + status: + type: string + enum: [pending, completed, failed] + type: + type: string + amount: + type: integer + created_at: + type: string + format: date-time + completed_at: + type: string + format: date-time + nullable: true + + BillingPlan: + type: object + x-runtime: [cloud] + description: "[cloud-only] A subscription plan with pricing details." + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + credits_per_month: + type: integer + price_cents: + type: integer + description: Monthly price in cents (USD) + currency: + type: string + default: usd + features: + type: array + items: + type: string + description: List of plan features + + BillingStatus: + type: object + x-runtime: [cloud] + description: "[cloud-only] Overall billing and subscription status." + properties: + subscription: + $ref: "#/components/schemas/BillingSubscription" + balance: + $ref: "#/components/schemas/BillingBalance" + has_payment_method: + type: boolean + + BillingSubscription: + type: object + x-runtime: [cloud] + description: "[cloud-only] Active subscription details." + required: + - id + - status + - plan_id + properties: + id: + type: string + status: + type: string + enum: [active, cancelled, past_due, trialing] + plan_id: + type: string + plan_name: + type: string + current_period_start: + type: string + format: date-time + current_period_end: + type: string + format: date-time + cancel_at_period_end: + type: boolean + + SubscriptionPreview: + type: object + x-runtime: [cloud] + description: "[cloud-only] Preview of a subscription change including prorations." + properties: + plan_id: + type: string + plan_name: + type: string + amount_due: + type: integer + description: Amount due in cents + proration_amount: + type: integer + description: Proration adjustment in cents + currency: + type: string + next_billing_date: + type: string + format: date-time + + Workspace: + type: object + x-runtime: [cloud] + description: "[cloud-only] A cloud workspace for team collaboration." + required: + - id + - name + properties: + id: + type: string + name: + type: string + owner_id: + type: string + member_count: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + WorkspaceMember: + type: object + x-runtime: [cloud] + description: "[cloud-only] A member of a cloud workspace." + required: + - user_id + - role + properties: + user_id: + type: string + email: + type: string + format: email + display_name: + type: string + avatar_url: + type: string + format: uri + role: + type: string + enum: [owner, admin, member] + joined_at: + type: string + format: date-time + + WorkspaceInvite: + type: object + x-runtime: [cloud] + description: "[cloud-only] A pending workspace invitation." + required: + - id + - email + - role + properties: + id: + type: string + email: + type: string + format: email + role: + type: string + enum: [admin, member] + invited_by: + type: string + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + + WorkspaceApiKey: + type: object + x-runtime: [cloud] + description: "[cloud-only] A workspace API key (secret value redacted)." + required: + - id + - name + properties: + id: + type: string + name: + type: string + prefix: + type: string + description: First few characters of the key for identification + created_at: + type: string + format: date-time + last_used_at: + type: string + format: date-time + nullable: true + created_by: + type: string + + WorkspaceApiKeyCreated: + type: object + x-runtime: [cloud] + description: "[cloud-only] A newly created workspace API key, including the full secret value (shown only once)." + required: + - id + - name + - key + properties: + id: + type: string + name: + type: string + key: + type: string + description: Full API key value (only returned on creation) + prefix: + type: string + created_at: + type: string + format: date-time + + CloudUser: + type: object + x-runtime: [cloud] + description: "[cloud-only] A cloud-authenticated user profile." + required: + - id + - email + properties: + id: + type: string + email: + type: string + format: email + display_name: + type: string + avatar_url: + type: string + format: uri + created_at: + type: string + format: date-time + + SecretMeta: + type: object + x-runtime: [cloud] + description: "[cloud-only] Metadata for a stored secret (value is never returned)." + required: + - id + - name + properties: + id: + type: string + name: + type: string + provider: + type: string + description: "[cloud-only] Provider identifier (e.g., huggingface, civitai)." + x-runtime: [cloud] + last_used_at: + type: string + format: date-time + description: "[cloud-only] When the secret was last used for decryption." + x-runtime: [cloud] + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + UpdateSecretRequest: + type: object + x-runtime: [cloud] + description: "[cloud-only] Request body for updating an existing user secret." + properties: + name: + type: string + description: New name for the secret + secret_value: + type: string + description: New secret value (API key, token, etc.) + + CreateSessionResponse: + type: object + x-runtime: [cloud] + description: "[cloud-only] Response after creating a session cookie." + required: + - success + properties: + success: + type: boolean + expiresIn: + type: integer + description: Session expiration time in seconds. + + DeleteSessionResponse: + type: object + x-runtime: [cloud] + description: "[cloud-only] Response after deleting a session cookie." + required: + - success + properties: + success: + type: boolean + + CreateHubProfileRequest: + type: object + x-runtime: [cloud] + description: "[cloud-only] Request body for creating a new Hub profile." + required: + - workspace_id + - username + properties: + workspace_id: + type: string + username: + type: string + description: Unique URL-safe slug. Immutable after creation. + display_name: + type: string + description: + type: string + avatar_token: + type: string + website_urls: + type: array + items: + type: string + + PublishHubWorkflowRequest: + type: object + x-runtime: [cloud] + description: "[cloud-only] Request body for publishing or updating a workflow on the Hub." + required: + - username + - name + - workflow_filename + - asset_ids + properties: + username: + type: string + name: + type: string + workflow_filename: + type: string + asset_ids: + type: array + items: + type: string + description: + type: string + tags: + type: array + items: + type: string + models: + type: array + items: + type: string + custom_nodes: + type: array + items: + type: string + tutorial_url: + type: string + metadata: + type: object + additionalProperties: true + thumbnail_type: + type: string + enum: [image, video, image_comparison] + thumbnail_token_or_url: + type: string + thumbnail_comparison_token_or_url: + type: string + sample_image_tokens_or_urls: + type: array + items: + type: string + + HubWorkflowDetail: + type: object + x-runtime: [cloud] + description: "[cloud-only] Full Hub workflow detail including versions, assets, and statistics." + required: + - share_id + - workflow_id + - name + - workflow_json + - assets + - profile + - status + properties: + share_id: + type: string + workflow_id: + type: string + name: + type: string + status: + type: string + enum: [pending, approved, rejected, deprecated] + description: + type: string + thumbnail_type: + type: string + enum: [image, video, image_comparison] + thumbnail_url: + type: string + thumbnail_comparison_url: + type: string + tutorial_url: + type: string + metadata: + type: object + additionalProperties: true + sample_image_urls: + type: array + items: + type: string + publish_time: + type: string + format: date-time + nullable: true + workflow_json: + type: object + additionalProperties: true + assets: + type: array + items: + $ref: "#/components/schemas/AssetInfo" + profile: + $ref: "#/components/schemas/HubProfile" + + AssetInfo: + type: object + x-runtime: [cloud] + description: "[cloud-only] Lightweight asset reference used in workflow publishing payloads." + required: + - id + - filename + properties: + id: + type: string + filename: + type: string + mime_type: + type: string + size_bytes: + type: integer + format: int64 + + BulkRevokeAPIKeysResponse: + type: object + x-runtime: [cloud] + description: "[cloud-only] Response after bulk-revoking API keys for a workspace member." + required: + - revoked_count + properties: + revoked_count: + type: integer + minimum: 0 + + CreateWorkflowVersionRequest: + type: object + x-runtime: [cloud] + description: "[cloud-only] Request body for creating a new version of a saved workflow." + required: + - base_version + - workflow_json + properties: + base_version: + type: integer + description: Version number this change is based on (for optimistic concurrency). + workflow_json: + type: object + additionalProperties: true + + WorkflowVersionResponse: + type: object + x-runtime: [cloud] + description: "[cloud-only] Metadata for a single workflow version." + required: + - id + - version + - latest_version + - created_by + - created_at + properties: + id: + type: string + version: + type: integer + latest_version: + type: integer + created_by: + type: string + created_at: + type: string + format: date-time + + WorkflowPublishInfo: + type: object + x-runtime: [cloud] + description: "[cloud-only] Publishing metadata for a workflow shared to the Hub." + required: + - workflow_id + - share_id + - listed + - assets + properties: + workflow_id: + type: string + share_id: + type: string + publish_time: + type: string + format: date-time + nullable: true + listed: + type: boolean + assets: + type: array + items: + $ref: "#/components/schemas/AssetInfo" + + TaskEntry: + type: object + x-runtime: [cloud] + description: "[cloud-only] Task data for list views." + required: + - id + - task_name + - status + - create_time + properties: + id: + type: string + format: uuid + task_name: + type: string + status: + type: string + enum: [created, running, completed, failed] + create_time: + type: string + format: date-time + started_at: + type: string + format: date-time + completed_at: + type: string + format: date-time + + TaskResponse: + type: object + x-runtime: [cloud] + description: "[cloud-only] Full task details including payload and result." + required: + - id + - idempotency_key + - task_name + - payload + - status + - create_time + - update_time + properties: + id: + type: string + format: uuid + idempotency_key: + type: string + task_name: + type: string + payload: + type: object + additionalProperties: true + status: + type: string + enum: [created, running, completed, failed] + result: + type: object + additionalProperties: true + create_time: + type: string + format: date-time + update_time: + type: string + format: date-time + started_at: + type: string + format: date-time + completed_at: + type: string + format: date-time + error: + type: string + + TasksListResponse: + type: object + x-runtime: [cloud] + description: "[cloud-only] Paginated list of background tasks for the authenticated user." + required: + - tasks + - pagination + properties: + tasks: + type: array + items: + $ref: "#/components/schemas/TaskEntry" + pagination: + $ref: "#/components/schemas/PaginationInfo" \ No newline at end of file From 65045730a60af0bf75cec2a738555a952da2ea4e Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Fri, 8 May 2026 23:11:52 +0300 Subject: [PATCH 49/56] [Partner Nodes] additionally use Baidu server to detect the accessibility of internet (#13803) Signed-off-by: bigcat88 --- comfy_api_nodes/util/client.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/comfy_api_nodes/util/client.py b/comfy_api_nodes/util/client.py index 8e1ba91ba..052301c33 100644 --- a/comfy_api_nodes/util/client.py +++ b/comfy_api_nodes/util/client.py @@ -488,10 +488,30 @@ async def _diagnose_connectivity() -> dict[str, bool]: "api_accessible": False, } timeout = aiohttp.ClientTimeout(total=5.0) + + # Probe Google and Baidu in parallel: Google is blocked by the GFW in mainland China, so a Baidu probe is required + # to correctly detect that Chinese users with working internet do have working internet. + internet_probe_urls = ("https://www.google.com", "https://www.baidu.com") + async with aiohttp.ClientSession(timeout=timeout) as session: - with contextlib.suppress(ClientError, OSError): - async with session.get("https://www.google.com") as resp: - results["internet_accessible"] = resp.status < 500 + async def _probe(url: str) -> bool: + try: + async with session.get(url) as resp: + return resp.status < 500 + except (ClientError, OSError, asyncio.TimeoutError): + return False + + probe_tasks = [asyncio.create_task(_probe(u)) for u in internet_probe_urls] + try: + for fut in asyncio.as_completed(probe_tasks): + if await fut: + results["internet_accessible"] = True + break + finally: + for t in probe_tasks: + if not t.done(): + t.cancel() + await asyncio.gather(*probe_tasks, return_exceptions=True) if not results["internet_accessible"]: return results From 66669b2ded7d8f362fdf64bb1c77a8df0f684e2f Mon Sep 17 00:00:00 2001 From: comfyanonymous <121283862+comfyanonymous@users.noreply.github.com> Date: Fri, 8 May 2026 17:32:14 -0700 Subject: [PATCH 50/56] I don't think there was any because nobody complained. (#13807) --- comfy/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy/utils.py b/comfy/utils.py index 7b7faad3a..91e1ba3d3 100644 --- a/comfy/utils.py +++ b/comfy/utils.py @@ -1390,7 +1390,7 @@ def convert_old_quants(state_dict, model_prefix="", metadata={}): k_out = "{}.weight_scale".format(layer) if layer is not None: - layer_conf = {"format": "float8_e4m3fn"} # TODO: check if anyone did some non e4m3fn scaled checkpoints + layer_conf = {"format": "float8_e4m3fn"} if full_precision_matrix_mult: layer_conf["full_precision_matrix_mult"] = full_precision_matrix_mult layers[layer] = layer_conf From 4e823431cc8291deced4fc2dcf3967be2549e4c0 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 8 May 2026 19:14:23 -0700 Subject: [PATCH 51/56] Add cloud-runtime experiment node-schema endpoints to spec (#13806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add cloud-runtime experiment node-schema endpoints to spec Replace the GET operations at /api/experiment/nodes and /api/experiment/nodes/{id} with getNodeInfoSchema and getNodeByID — the optimized, ETag-tagged object_info schema endpoints the cloud frontend depends on for the workflow editor. Each operation is tagged x-runtime: [cloud] and uses the runtime-only tag for cloud-side codegen exclusion. Response headers document the ETag and Cache-Control validators; 304 Not Modified is declared for RFC 7232 conditional GETs. Remove the now-unused CloudNodeList schema to keep Spectral clean. Co-authored-by: Matt Miller * spec: document If-None-Match header on conditional GET endpoints Both `getNodeInfoSchema` and `getNodeByID` advertise `ETag` response headers and a `304 Not Modified` response, but the spec didn't declare the `If-None-Match` request header that triggers conditional validation. Adding it as an optional header parameter on both ops so client codegen exposes the conditional-GET pattern. --- openapi.yaml | 106 +++++++++++++++++++++++++-------------------------- 1 file changed, 51 insertions(+), 55 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 4216c1a6c..d4c9e67ca 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -74,6 +74,8 @@ tags: description: Cloud workflow management and versioning (cloud-only) - name: task description: Background task management (cloud-only) + - name: runtime-only + description: Operations served exclusively by the cloud runtime with no local equivalent paths: # --------------------------------------------------------------------------- @@ -2573,35 +2575,38 @@ paths: # --------------------------------------------------------------------------- /api/experiment/nodes: get: - operationId: listCloudNodes - tags: [node] - summary: List installed custom nodes - description: "[cloud-only] Returns the list of custom node packages installed in the cloud runtime." + operationId: getNodeInfoSchema + tags: [runtime-only] + summary: Get pre-rendered node info schema + description: "[cloud-only] Returns the static ComfyUI object_info schema, identical for every caller, rendered once at startup with empty model/user-file context. Served by a raw HTTP handler that writes pre-rendered bytes with ETag + Cache-Control validators for RFC 7232 conditional GETs." x-runtime: [cloud] parameters: - - name: limit - in: query + - name: If-None-Match + in: header + required: false schema: - type: integer - description: Maximum number of results - - name: offset - in: query - schema: - type: integer - description: Pagination offset + type: string + description: Entity tag previously returned by this endpoint. When present and matching, the server returns 304 Not Modified. responses: "200": - description: Custom node list + description: Node info schema + headers: + ETag: + schema: + type: string + description: Entity tag for conditional request validation + Cache-Control: + schema: + type: string + description: Cache directives for the response content: application/json: schema: - $ref: "#/components/schemas/CloudNodeList" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/CloudError" + type: object + additionalProperties: + $ref: "#/components/schemas/NodeInfo" + "304": + description: Not Modified — returned when the client sends a matching If-None-Match header post: operationId: installCloudNode tags: [node] @@ -2651,10 +2656,10 @@ paths: /api/experiment/nodes/{id}: get: - operationId: getCloudNode - tags: [node] - summary: Get details of an installed custom node - description: "[cloud-only] Returns details about a specific installed custom node package." + operationId: getNodeByID + tags: [runtime-only] + summary: Get a single node definition by ID + description: "[cloud-only] Returns one node's definition from the pre-indexed object_info schema. Served by a raw HTTP handler that writes pre-rendered bytes with ETag + Cache-Control validators for RFC 7232 conditional GETs." x-runtime: [cloud] parameters: - name: id @@ -2662,26 +2667,33 @@ paths: required: true schema: type: string - description: Custom node package ID + description: Node class identifier + - name: If-None-Match + in: header + required: false + schema: + type: string + description: Entity tag previously returned by this endpoint. When present and matching, the server returns 304 Not Modified. responses: "200": - description: Node detail + description: Single node definition + headers: + ETag: + schema: + type: string + description: Entity tag for conditional request validation + Cache-Control: + schema: + type: string + description: Cache directives for the response content: application/json: schema: - $ref: "#/components/schemas/CloudNode" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/CloudError" + $ref: "#/components/schemas/NodeInfo" + "304": + description: Not Modified — returned when the client sends a matching If-None-Match header "404": - description: Not found - content: - application/json: - schema: - $ref: "#/components/schemas/CloudError" + description: Node not found delete: operationId: uninstallCloudNode tags: [node] @@ -7100,22 +7112,6 @@ components: enabled: type: boolean - CloudNodeList: - type: object - x-runtime: [cloud] - description: "[cloud-only] Paginated list of installed custom node packages." - required: - - nodes - properties: - nodes: - type: array - items: - $ref: "#/components/schemas/CloudNode" - total: - type: integer - has_more: - type: boolean - HubLabel: type: object x-runtime: [cloud] From 8b08bfdcbe2b4cd8f4426bd1111aaf17b118e33d Mon Sep 17 00:00:00 2001 From: lin-bot23 Date: Sat, 9 May 2026 12:26:13 +0900 Subject: [PATCH 52/56] Add description field to blueprint subgraphs (#13797) * Add description field to all blueprint subgraphs Sets the 'description' field on every subgraph blueprint node, which will show on the node preview and tooltip. Covers all 51 blueprint files under blueprints/. * Update blueprint descriptions with researched model info * Refine blueprint descriptions with researched model specs from docs Updates subgraph descriptions across all 51 blueprints with accurate model details drawn from ComfyUI docs, including: - Flux.1 Dev: 12B open-weights, Pro-level quality - Flux.2 Klein 4B: fastest Flux, distilled architecture - Qwen-Image: 20B MMDiT, multilingual text rendering - Z-Image-Turbo: distilled 6B DiT, sub-second inference - LTX-2/2.3: 19B DiT audio-video foundation model - Wan2.2: open-source, 14B/1.3B variants - ACE-Step 1.5: ~1s full-song generation - GPU shader nodes consistently labeled as fragment shaders * Strip marketing fluff and license info from descriptions * Fix Canny to Video (LTX 2.0) description * Remove 'local-' prefix from subgraph names * Preserve UTF-8 encoding in JSON files (ensure_ascii=False) * Apply review suggestions from alexisrolland - Rename 'Image to Model (Hunyuan3d 2.1)' -> 'Image to 3D Model (Hunyuan3d 2.1)' - Rename 'Image Upscale(Z-image-Turbo)' -> 'Image Upscale (Z-image-Turbo)' - Rename 'Video Inpaint(Wan2.1 VACE)' -> 'Video Inpaint (Wan 2.1 VACE)' - Use 'Black Forest Labs' branding in Flux descriptions - Use 'Google's Gemini' with possessive in captioning nodes - Normalize 'Wan 2.2' and 'Wan 2.1' spacing in descriptions * fix: revert Color Adjustment.json to preserve original GLSL shader content Only adds the 'description' field without modifying the shader code (which contained Unicode escape \\u2192 that should be preserved). * Apply CodeRabbit review suggestions - Color Adjustment: include vibrance in description - Image Blur: expand to Gaussian/Box/Radial modes - Flux.2 Klein 4B: narrow to image edit only (no T2I) - NetaYume Lumina: correct model base (Neta Lumina, not Lumina-Next) --------- Co-authored-by: linmoumou Co-authored-by: Daxiong (Lin) --- blueprints/Brightness and Contrast.json | 5 +++-- blueprints/Canny to Image (Z-Image-Turbo).json | 7 ++++--- blueprints/Canny to Video (LTX 2.0).json | 7 ++++--- blueprints/Chromatic Aberration.json | 5 +++-- blueprints/Color Adjustment.json | 3 ++- blueprints/Color Balance.json | 3 ++- blueprints/Color Curves.json | 3 ++- blueprints/Crop Images 2x2.json | 3 ++- blueprints/Crop Images 3x3.json | 3 ++- blueprints/Depth to Image (Z-Image-Turbo).json | 6 ++++-- blueprints/Depth to Video (ltx 2.0).json | 6 ++++-- blueprints/Edge-Preserving Blur.json | 5 +++-- blueprints/Film Grain.json | 5 +++-- blueprints/First-Last-Frame to Video (LTX-2.3).json | 3 ++- blueprints/Glow.json | 5 +++-- blueprints/Hue and Saturation.json | 5 +++-- blueprints/Image Blur.json | 3 ++- blueprints/Image Captioning (gemini).json | 3 ++- blueprints/Image Channels.json | 5 +++-- blueprints/Image Edit (FireRed Image Edit 1.1).json | 3 ++- blueprints/Image Edit (Flux.2 Klein 4B).json | 8 +++++--- blueprints/Image Edit (LongCat Image Edit).json | 3 ++- blueprints/Image Edit (Qwen 2511).json | 7 ++++--- blueprints/Image Inpainting (Flux.1 Fill Dev).json | 5 +++-- blueprints/Image Inpainting (Qwen-image).json | 6 ++++-- blueprints/Image Levels.json | 5 +++-- blueprints/Image Outpainting (Qwen-Image).json | 9 ++++++--- blueprints/Image Upscale(Z-image-Turbo).json | 5 +++-- blueprints/Image to Depth Map (Lotus).json | 7 ++++--- blueprints/Image to Layers(Qwen-Image-Layered).json | 3 ++- blueprints/Image to Model (Hunyuan3d 2.1).json | 5 +++-- blueprints/Image to Video (LTX-2.3).json | 3 ++- blueprints/Image to Video (Wan 2.2).json | 5 +++-- blueprints/Pose to Image (Z-Image-Turbo).json | 7 ++++--- blueprints/Pose to Video (LTX 2.0).json | 3 ++- blueprints/Prompt Enhance.json | 5 +++-- blueprints/Sharpen.json | 5 +++-- blueprints/Text to Audio (ACE-Step 1.5).json | 7 ++++--- blueprints/Text to Image (Flux.1 Dev).json | 5 +++-- blueprints/Text to Image (Flux.1 Krea Dev).json | 5 +++-- blueprints/Text to Image (NetaYume Lumina).json | 8 +++++--- blueprints/Text to Image (Qwen-Image 2512).json | 3 ++- blueprints/Text to Image (Qwen-Image).json | 3 ++- blueprints/Text to Image (Z-Image-Turbo).json | 7 ++++--- blueprints/Text to Video (LTX-2.3).json | 3 ++- blueprints/Text to Video (Wan 2.2).json | 5 +++-- blueprints/Unsharp Mask.json | 5 +++-- blueprints/Video Captioning (Gemini).json | 3 ++- blueprints/Video Inpaint(Wan2.1 VACE).json | 5 +++-- blueprints/Video Stitch.json | 5 +++-- blueprints/Video Upscale(GAN x4).json | 5 +++-- 51 files changed, 153 insertions(+), 95 deletions(-) diff --git a/blueprints/Brightness and Contrast.json b/blueprints/Brightness and Contrast.json index 90bfe999d..78fc52f29 100644 --- a/blueprints/Brightness and Contrast.json +++ b/blueprints/Brightness and Contrast.json @@ -431,9 +431,10 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Color adjust" + "category": "Image Tools/Color adjust", + "description": "Adjusts image brightness and contrast using a real-time GPU fragment shader." } ] }, "extra": {} -} +} \ No newline at end of file diff --git a/blueprints/Canny to Image (Z-Image-Turbo).json b/blueprints/Canny to Image (Z-Image-Turbo).json index ff9717308..14deb64cc 100644 --- a/blueprints/Canny to Image (Z-Image-Turbo).json +++ b/blueprints/Canny to Image (Z-Image-Turbo).json @@ -162,7 +162,7 @@ }, "revision": 0, "config": {}, - "name": "local-Canny to Image (Z-Image-Turbo)", + "name": "Canny to Image (Z-Image-Turbo)", "inputNode": { "id": -10, "bounding": [ @@ -1553,7 +1553,8 @@ "VHS_MetadataImage": true, "VHS_KeepIntermediate": true }, - "category": "Image generation and editing/Canny to image" + "category": "Image generation and editing/Canny to image", + "description": "Generates an image from a Canny edge map using Z-Image-Turbo, with text conditioning." } ] }, @@ -1574,4 +1575,4 @@ } }, "version": 0.4 -} +} \ No newline at end of file diff --git a/blueprints/Canny to Video (LTX 2.0).json b/blueprints/Canny to Video (LTX 2.0).json index fae8321b9..a9682c8a4 100644 --- a/blueprints/Canny to Video (LTX 2.0).json +++ b/blueprints/Canny to Video (LTX 2.0).json @@ -192,7 +192,7 @@ }, "revision": 0, "config": {}, - "name": "local-Canny to Video (LTX 2.0)", + "name": "Canny to Video (LTX 2.0)", "inputNode": { "id": -10, "bounding": [ @@ -3600,7 +3600,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Canny to video" + "category": "Video generation and editing/Canny to video", + "description": "Generates video from Canny edge maps using LTX-2, with optional synchronized audio." } ] }, @@ -3616,4 +3617,4 @@ } }, "version": 0.4 -} +} \ No newline at end of file diff --git a/blueprints/Chromatic Aberration.json b/blueprints/Chromatic Aberration.json index ae8037b1b..893fb1190 100644 --- a/blueprints/Chromatic Aberration.json +++ b/blueprints/Chromatic Aberration.json @@ -377,8 +377,9 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Color adjust" + "category": "Image Tools/Color adjust", + "description": "Adds lens-style chromatic aberration (color fringing) using a real-time GPU fragment shader." } ] } -} +} \ No newline at end of file diff --git a/blueprints/Color Adjustment.json b/blueprints/Color Adjustment.json index 622bf28af..5abbf8baa 100644 --- a/blueprints/Color Adjustment.json +++ b/blueprints/Color Adjustment.json @@ -596,7 +596,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Color adjust" + "category": "Image Tools/Color adjust", + "description": "Adjusts saturation, temperature, tint, and vibrance using a real-time GPU fragment shader." } ] } diff --git a/blueprints/Color Balance.json b/blueprints/Color Balance.json index 21d6319ed..d921eab37 100644 --- a/blueprints/Color Balance.json +++ b/blueprints/Color Balance.json @@ -1129,7 +1129,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Color adjust" + "category": "Image Tools/Color adjust", + "description": "Balances colors across shadows, midtones, and highlights using a real-time GPU fragment shader." } ] } diff --git a/blueprints/Color Curves.json b/blueprints/Color Curves.json index 1461cf396..b9bfb7029 100644 --- a/blueprints/Color Curves.json +++ b/blueprints/Color Curves.json @@ -608,7 +608,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Color adjust" + "category": "Image Tools/Color adjust", + "description": "Fine-tunes tone and color with per-channel curve adjustments using a real-time GPU fragment shader." } ] } diff --git a/blueprints/Crop Images 2x2.json b/blueprints/Crop Images 2x2.json index 2aa42cfc3..99b89b608 100644 --- a/blueprints/Crop Images 2x2.json +++ b/blueprints/Crop Images 2x2.json @@ -1609,7 +1609,8 @@ } ], "extra": {}, - "category": "Image Tools/Crop" + "category": "Image Tools/Crop", + "description": "Splits an image into a 2×2 grid of four equal tiles." } ] }, diff --git a/blueprints/Crop Images 3x3.json b/blueprints/Crop Images 3x3.json index 3a3615ac8..6ac636da4 100644 --- a/blueprints/Crop Images 3x3.json +++ b/blueprints/Crop Images 3x3.json @@ -2946,7 +2946,8 @@ } ], "extra": {}, - "category": "Image Tools/Crop" + "category": "Image Tools/Crop", + "description": "Splits an image into a 3×3 grid of nine equal tiles." } ] }, diff --git a/blueprints/Depth to Image (Z-Image-Turbo).json b/blueprints/Depth to Image (Z-Image-Turbo).json index 4f69a8149..fe9ef0f72 100644 --- a/blueprints/Depth to Image (Z-Image-Turbo).json +++ b/blueprints/Depth to Image (Z-Image-Turbo).json @@ -1579,7 +1579,8 @@ "VHS_MetadataImage": true, "VHS_KeepIntermediate": true }, - "category": "Image generation and editing/Depth to image" + "category": "Image generation and editing/Depth to image", + "description": "Generates an image from a depth map using Z-Image-Turbo with text conditioning." }, { "id": "458bdf3c-4b58-421c-af50-c9c663a4d74c", @@ -2461,7 +2462,8 @@ ] }, "workflowRendererVersion": "LG" - } + }, + "description": "Estimates a monocular depth map from an input image using the Lotus depth estimation model." } ] }, diff --git a/blueprints/Depth to Video (ltx 2.0).json b/blueprints/Depth to Video (ltx 2.0).json index f15212520..bb28695a2 100644 --- a/blueprints/Depth to Video (ltx 2.0).json +++ b/blueprints/Depth to Video (ltx 2.0).json @@ -4233,7 +4233,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Depth to video" + "category": "Video generation and editing/Depth to video", + "description": "Generates video from depth maps using LTX-2, with optional synchronized audio." }, { "id": "38b60539-50a7-42f9-a5fe-bdeca26272e2", @@ -5192,7 +5193,8 @@ ], "extra": { "workflowRendererVersion": "LG" - } + }, + "description": "Estimates a monocular depth map from an input image using the Lotus depth estimation model." } ] }, diff --git a/blueprints/Edge-Preserving Blur.json b/blueprints/Edge-Preserving Blur.json index 18012beb1..fbda9f126 100644 --- a/blueprints/Edge-Preserving Blur.json +++ b/blueprints/Edge-Preserving Blur.json @@ -450,9 +450,10 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Blur" + "category": "Image Tools/Blur", + "description": "Applies bilateral (edge-preserving) blur to soften images while retaining detail." } ] }, "extra": {} -} +} \ No newline at end of file diff --git a/blueprints/Film Grain.json b/blueprints/Film Grain.json index a680b3ece..3226ea9aa 100644 --- a/blueprints/Film Grain.json +++ b/blueprints/Film Grain.json @@ -580,8 +580,9 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Color adjust" + "category": "Image Tools/Color adjust", + "description": "Adds procedural film grain texture for a cinematic look via GPU fragment shader." } ] } -} +} \ No newline at end of file diff --git a/blueprints/First-Last-Frame to Video (LTX-2.3).json b/blueprints/First-Last-Frame to Video (LTX-2.3).json index 8ec9ed61a..f509aefe0 100644 --- a/blueprints/First-Last-Frame to Video (LTX-2.3).json +++ b/blueprints/First-Last-Frame to Video (LTX-2.3).json @@ -3350,7 +3350,8 @@ } ], "extra": {}, - "category": "Video generation and editing/First-Last-Frame to Video" + "category": "Video generation and editing/First-Last-Frame to Video", + "description": "Generates a video interpolating between first and last keyframes using LTX-2.3." } ] }, diff --git a/blueprints/Glow.json b/blueprints/Glow.json index 1dafb2d35..2bbfdee51 100644 --- a/blueprints/Glow.json +++ b/blueprints/Glow.json @@ -575,8 +575,9 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Color adjust" + "category": "Image Tools/Color adjust", + "description": "Adds a glow/bloom effect around bright image areas via GPU fragment shader." } ] } -} +} \ No newline at end of file diff --git a/blueprints/Hue and Saturation.json b/blueprints/Hue and Saturation.json index 1a2df8937..cddf0154a 100644 --- a/blueprints/Hue and Saturation.json +++ b/blueprints/Hue and Saturation.json @@ -752,8 +752,9 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Color adjust" + "category": "Image Tools/Color adjust", + "description": "Adjusts hue, saturation, and lightness of an image using a real-time GPU fragment shader." } ] } -} +} \ No newline at end of file diff --git a/blueprints/Image Blur.json b/blueprints/Image Blur.json index 3c7a784b0..0ca8d9931 100644 --- a/blueprints/Image Blur.json +++ b/blueprints/Image Blur.json @@ -374,7 +374,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Blur" + "category": "Image Tools/Blur", + "description": "Applies Gaussian, Box, or Radial blur to soften images and create stylized depth or motion effects." } ] } diff --git a/blueprints/Image Captioning (gemini).json b/blueprints/Image Captioning (gemini).json index 98cfb8999..2fc5d6746 100644 --- a/blueprints/Image Captioning (gemini).json +++ b/blueprints/Image Captioning (gemini).json @@ -310,7 +310,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Text generation/Image Captioning" + "category": "Text generation/Image Captioning", + "description": "Generates descriptive captions for images using Google's Gemini multimodal LLM." } ] } diff --git a/blueprints/Image Channels.json b/blueprints/Image Channels.json index 9c7b675b2..b6fdff5be 100644 --- a/blueprints/Image Channels.json +++ b/blueprints/Image Channels.json @@ -315,8 +315,9 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Color adjust" + "category": "Image Tools/Color adjust", + "description": "Manipulates individual RGBA channels for masking, compositing, and channel effects." } ] } -} +} \ No newline at end of file diff --git a/blueprints/Image Edit (FireRed Image Edit 1.1).json b/blueprints/Image Edit (FireRed Image Edit 1.1).json index c34246ce6..14310353c 100644 --- a/blueprints/Image Edit (FireRed Image Edit 1.1).json +++ b/blueprints/Image Edit (FireRed Image Edit 1.1).json @@ -2138,7 +2138,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Edit image" + "category": "Image generation and editing/Edit image", + "description": "Edits images via text instructions using FireRed Image Edit 1.1, a diffusion-based instruction-following editing model." } ] }, diff --git a/blueprints/Image Edit (Flux.2 Klein 4B).json b/blueprints/Image Edit (Flux.2 Klein 4B).json index 6f2f7dc01..7f6fa7a4b 100644 --- a/blueprints/Image Edit (Flux.2 Klein 4B).json +++ b/blueprints/Image Edit (Flux.2 Klein 4B).json @@ -1472,7 +1472,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Edit image" + "category": "Image generation and editing/Edit image", + "description": "Edits an input image via text instructions using FLUX.2 [klein] 4B." }, { "id": "6007e698-2ebd-4917-84d8-299b35d7b7ab", @@ -1821,7 +1822,8 @@ ], "extra": { "workflowRendererVersion": "LG" - } + }, + "description": "Applies reference image conditioning for style/identity transfer (Flux.2 Klein 4B)." } ] }, @@ -1837,4 +1839,4 @@ } }, "version": 0.4 -} \ No newline at end of file +} diff --git a/blueprints/Image Edit (LongCat Image Edit).json b/blueprints/Image Edit (LongCat Image Edit).json index 5b4eb18f0..de1c155a2 100644 --- a/blueprints/Image Edit (LongCat Image Edit).json +++ b/blueprints/Image Edit (LongCat Image Edit).json @@ -1417,7 +1417,8 @@ } ], "extra": {}, - "category": "Image generation and editing/Edit image" + "category": "Image generation and editing/Edit image", + "description": "Edits images via text instructions using LongCat Image Edit, an instruction-following image editing diffusion model." } ] }, diff --git a/blueprints/Image Edit (Qwen 2511).json b/blueprints/Image Edit (Qwen 2511).json index 582171fa0..1aa7e5765 100644 --- a/blueprints/Image Edit (Qwen 2511).json +++ b/blueprints/Image Edit (Qwen 2511).json @@ -132,7 +132,7 @@ }, "revision": 0, "config": {}, - "name": "local-Image Edit (Qwen 2511)", + "name": "Image Edit (Qwen 2511)", "inputNode": { "id": -10, "bounding": [ @@ -1468,7 +1468,8 @@ "VHS_MetadataImage": true, "VHS_KeepIntermediate": true }, - "category": "Image generation and editing/Edit image" + "category": "Image generation and editing/Edit image", + "description": "Edits images via text instructions using Qwen-Image-Edit-2511 with improved character consistency and integrated LoRA." } ] }, @@ -1489,4 +1490,4 @@ } }, "version": 0.4 -} +} \ No newline at end of file diff --git a/blueprints/Image Inpainting (Flux.1 Fill Dev).json b/blueprints/Image Inpainting (Flux.1 Fill Dev).json index d40d63594..c1326ed3d 100644 --- a/blueprints/Image Inpainting (Flux.1 Fill Dev).json +++ b/blueprints/Image Inpainting (Flux.1 Fill Dev).json @@ -1188,7 +1188,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Inpaint image" + "category": "Image generation and editing/Inpaint image", + "description": "Inpaints masked image regions using Flux.1 fill [dev], Black Forest Labs' inpainting/outpainting model." } ] }, @@ -1202,4 +1203,4 @@ }, "ue_links": [] } -} \ No newline at end of file +} diff --git a/blueprints/Image Inpainting (Qwen-image).json b/blueprints/Image Inpainting (Qwen-image).json index 95b2909fa..a06d57e19 100644 --- a/blueprints/Image Inpainting (Qwen-image).json +++ b/blueprints/Image Inpainting (Qwen-image).json @@ -1548,7 +1548,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Inpaint image" + "category": "Image generation and editing/Inpaint image", + "description": "Inpaints masked regions using Qwen-Image, extending its multilingual text rendering to inpainting tasks." }, { "id": "56a1f603-fbd2-40ed-94ef-c9ecbd96aca8", @@ -1907,7 +1908,8 @@ ], "extra": { "workflowRendererVersion": "LG" - } + }, + "description": "Expands and softens mask edges to reduce visible seams after image processing." } ] }, diff --git a/blueprints/Image Levels.json b/blueprints/Image Levels.json index ef256a1aa..1a1b18932 100644 --- a/blueprints/Image Levels.json +++ b/blueprints/Image Levels.json @@ -742,9 +742,10 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Color adjust" + "category": "Image Tools/Color adjust", + "description": "Adjusts black point, white point, and gamma for tonal range control via GPU shader." } ] }, "extra": {} -} +} \ No newline at end of file diff --git a/blueprints/Image Outpainting (Qwen-Image).json b/blueprints/Image Outpainting (Qwen-Image).json index 218fdc775..6c07227c0 100644 --- a/blueprints/Image Outpainting (Qwen-Image).json +++ b/blueprints/Image Outpainting (Qwen-Image).json @@ -1919,7 +1919,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Outpaint image" + "category": "Image generation and editing/Outpaint image", + "description": "Outpaints beyond image boundaries using Qwen-Image's outpainting capabilities." }, { "id": "f93c215e-c393-460e-9534-ed2c3d8a652e", @@ -2278,7 +2279,8 @@ ], "extra": { "workflowRendererVersion": "LG" - } + }, + "description": "Expands and softens mask edges to reduce visible seams after image processing." }, { "id": "2a4b2cc0-db37-4302-a067-da392f38f06b", @@ -2733,7 +2735,8 @@ ], "extra": { "workflowRendererVersion": "LG" - } + }, + "description": "Scales both image and mask together while preserving alignment for editing workflows." } ] }, diff --git a/blueprints/Image Upscale(Z-image-Turbo).json b/blueprints/Image Upscale(Z-image-Turbo).json index 0d2b6e240..bd803a0b1 100644 --- a/blueprints/Image Upscale(Z-image-Turbo).json +++ b/blueprints/Image Upscale(Z-image-Turbo).json @@ -141,7 +141,7 @@ }, "revision": 0, "config": {}, - "name": "local-Image Upscale(Z-image-Turbo)", + "name": "Image Upscale (Z-image-Turbo)", "inputNode": { "id": -10, "bounding": [ @@ -1302,7 +1302,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Enhance" + "category": "Image generation and editing/Enhance", + "description": "Upscales images to higher resolution using Z-Image-Turbo." } ] }, diff --git a/blueprints/Image to Depth Map (Lotus).json b/blueprints/Image to Depth Map (Lotus).json index 089f2cd42..12f10ba5b 100644 --- a/blueprints/Image to Depth Map (Lotus).json +++ b/blueprints/Image to Depth Map (Lotus).json @@ -99,7 +99,7 @@ }, "revision": 0, "config": {}, - "name": "local-Image to Depth Map (Lotus)", + "name": "Image to Depth Map (Lotus)", "inputNode": { "id": -10, "bounding": [ @@ -948,7 +948,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Depth to image" + "category": "Image generation and editing/Depth to image", + "description": "Estimates a monocular depth map from an input image using the Lotus depth estimation model." } ] }, @@ -964,4 +965,4 @@ "workflowRendererVersion": "LG" }, "version": 0.4 -} +} \ No newline at end of file diff --git a/blueprints/Image to Layers(Qwen-Image-Layered).json b/blueprints/Image to Layers(Qwen-Image-Layered).json index 8a525e7a5..7b44f0563 100644 --- a/blueprints/Image to Layers(Qwen-Image-Layered).json +++ b/blueprints/Image to Layers(Qwen-Image-Layered).json @@ -1586,7 +1586,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Image to layers" + "category": "Image generation and editing/Image to layers", + "description": "Decomposes an image into variable-resolution RGBA layers for independent editing using Qwen-Image-Layered." } ] }, diff --git a/blueprints/Image to Model (Hunyuan3d 2.1).json b/blueprints/Image to Model (Hunyuan3d 2.1).json index 4705603a8..ee5552656 100644 --- a/blueprints/Image to Model (Hunyuan3d 2.1).json +++ b/blueprints/Image to Model (Hunyuan3d 2.1).json @@ -72,7 +72,7 @@ }, "revision": 0, "config": {}, - "name": "local-Image to Model (Hunyuan3d 2.1)", + "name": "Image to 3D Model (Hunyuan3d 2.1)", "inputNode": { "id": -10, "bounding": [ @@ -765,7 +765,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "3D/Image to 3D Model" + "category": "3D/Image to 3D Model", + "description": "Generates 3D mesh models from a single input image using Hunyuan3D 2.0/2.1." } ] }, diff --git a/blueprints/Image to Video (LTX-2.3).json b/blueprints/Image to Video (LTX-2.3).json index 86a601130..3db524ea0 100644 --- a/blueprints/Image to Video (LTX-2.3).json +++ b/blueprints/Image to Video (LTX-2.3).json @@ -4223,7 +4223,8 @@ "extra": { "workflowRendererVersion": "Vue-corrected" }, - "category": "Video generation and editing/Image to video" + "category": "Video generation and editing/Image to video", + "description": "Generates video from a single input image using LTX-2.3." } ] }, diff --git a/blueprints/Image to Video (Wan 2.2).json b/blueprints/Image to Video (Wan 2.2).json index a8dafd3c9..3510aad18 100644 --- a/blueprints/Image to Video (Wan 2.2).json +++ b/blueprints/Image to Video (Wan 2.2).json @@ -206,7 +206,7 @@ }, "revision": 0, "config": {}, - "name": "local-Image to Video (Wan 2.2)", + "name": "Image to Video (Wan 2.2)", "inputNode": { "id": -10, "bounding": [ @@ -2027,7 +2027,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Image to video" + "category": "Video generation and editing/Image to video", + "description": "Generates video from an image and text prompt using Wan 2.2, supporting T2V and I2V." } ] }, diff --git a/blueprints/Pose to Image (Z-Image-Turbo).json b/blueprints/Pose to Image (Z-Image-Turbo).json index a55410ba4..5c2749efe 100644 --- a/blueprints/Pose to Image (Z-Image-Turbo).json +++ b/blueprints/Pose to Image (Z-Image-Turbo).json @@ -134,7 +134,7 @@ }, "revision": 0, "config": {}, - "name": "local-Pose to Image (Z-Image-Turbo)", + "name": "Pose to Image (Z-Image-Turbo)", "inputNode": { "id": -10, "bounding": [ @@ -1298,7 +1298,8 @@ "VHS_MetadataImage": true, "VHS_KeepIntermediate": true }, - "category": "Image generation and editing/Pose to image" + "category": "Image generation and editing/Pose to image", + "description": "Generates an image from pose keypoints using Z-Image-Turbo with text conditioning." } ] }, @@ -1319,4 +1320,4 @@ } }, "version": 0.4 -} +} \ No newline at end of file diff --git a/blueprints/Pose to Video (LTX 2.0).json b/blueprints/Pose to Video (LTX 2.0).json index 580900bc0..1ce49351a 100644 --- a/blueprints/Pose to Video (LTX 2.0).json +++ b/blueprints/Pose to Video (LTX 2.0).json @@ -3870,7 +3870,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Pose to video" + "category": "Video generation and editing/Pose to video", + "description": "Generates video from pose reference frames using LTX-2, with optional synchronized audio." } ] }, diff --git a/blueprints/Prompt Enhance.json b/blueprints/Prompt Enhance.json index 5e57548ff..e260b1203 100644 --- a/blueprints/Prompt Enhance.json +++ b/blueprints/Prompt Enhance.json @@ -270,9 +270,10 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Text generation/Prompt enhance" + "category": "Text generation/Prompt enhance", + "description": "Expands short text prompts into detailed descriptions using a text generation model for better generation quality." } ] }, "extra": {} -} +} \ No newline at end of file diff --git a/blueprints/Sharpen.json b/blueprints/Sharpen.json index f332400fd..3c4099c6b 100644 --- a/blueprints/Sharpen.json +++ b/blueprints/Sharpen.json @@ -302,8 +302,9 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Sharpen" + "category": "Image Tools/Sharpen", + "description": "Sharpens image details using a GPU fragment shader for enhanced clarity." } ] } -} +} \ No newline at end of file diff --git a/blueprints/Text to Audio (ACE-Step 1.5).json b/blueprints/Text to Audio (ACE-Step 1.5).json index 206cf16be..5b8b8626f 100644 --- a/blueprints/Text to Audio (ACE-Step 1.5).json +++ b/blueprints/Text to Audio (ACE-Step 1.5).json @@ -222,7 +222,7 @@ }, "revision": 0, "config": {}, - "name": "local-Text to Audio (ACE-Step 1.5)", + "name": "Text to Audio (ACE-Step 1.5)", "inputNode": { "id": -10, "bounding": [ @@ -1502,7 +1502,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Audio/Music generation" + "category": "Audio/Music generation", + "description": "Generates audio/music from text prompts using ACE-Step 1.5, a diffusion-based audio generation model." } ] }, @@ -1518,4 +1519,4 @@ } }, "version": 0.4 -} +} \ No newline at end of file diff --git a/blueprints/Text to Image (Flux.1 Dev).json b/blueprints/Text to Image (Flux.1 Dev).json index 04c3cb95a..45f68f508 100644 --- a/blueprints/Text to Image (Flux.1 Dev).json +++ b/blueprints/Text to Image (Flux.1 Dev).json @@ -1029,7 +1029,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image" + "category": "Image generation and editing/Text to image", + "description": "Generates images from text prompts using Flux.1 [dev], Black Forest Labs' 12B diffusion model." } ] }, @@ -1043,4 +1044,4 @@ }, "ue_links": [] } -} \ No newline at end of file +} diff --git a/blueprints/Text to Image (Flux.1 Krea Dev).json b/blueprints/Text to Image (Flux.1 Krea Dev).json index fe4db1cfc..30a78dca1 100644 --- a/blueprints/Text to Image (Flux.1 Krea Dev).json +++ b/blueprints/Text to Image (Flux.1 Krea Dev).json @@ -1023,7 +1023,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image" + "category": "Image generation and editing/Text to image", + "description": "Generates images from text prompts using Flux.1 Krea Dev, a Black Forest Labs × Krea collaboration variant." } ] }, @@ -1037,4 +1038,4 @@ }, "ue_links": [] } -} \ No newline at end of file +} diff --git a/blueprints/Text to Image (NetaYume Lumina).json b/blueprints/Text to Image (NetaYume Lumina).json index 394ad1608..9e11b7a86 100644 --- a/blueprints/Text to Image (NetaYume Lumina).json +++ b/blueprints/Text to Image (NetaYume Lumina).json @@ -1104,7 +1104,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image" + "category": "Image generation and editing/Text to image", + "description": "Generates images from text prompts using NetaYume Lumina, fine-tuned from Neta Lumina for anime-style and illustration generation." }, { "id": "a07fdf06-1bda-4dac-bdbd-63ee8ebca1c9", @@ -1458,11 +1459,12 @@ ], "extra": { "workflowRendererVersion": "LG" - } + }, + "description": "Encodes a negative text prompt via CLIP for classifier-free guidance in anime-style generation (NetaYume Lumina)." } ] }, "extra": { "ue_links": [] } -} \ No newline at end of file +} diff --git a/blueprints/Text to Image (Qwen-Image 2512).json b/blueprints/Text to Image (Qwen-Image 2512).json index f52ea2ef2..09612be8b 100644 --- a/blueprints/Text to Image (Qwen-Image 2512).json +++ b/blueprints/Text to Image (Qwen-Image 2512).json @@ -1941,7 +1941,8 @@ "extra": { "workflowRendererVersion": "Vue-corrected" }, - "category": "Image generation and editing/Text to image" + "category": "Image generation and editing/Text to image", + "description": "Generates images from text prompts using Qwen-Image-2512, with enhanced human realism and finer natural detail over the base version." } ] }, diff --git a/blueprints/Text to Image (Qwen-Image).json b/blueprints/Text to Image (Qwen-Image).json index 70b4b44b3..e78d5a962 100644 --- a/blueprints/Text to Image (Qwen-Image).json +++ b/blueprints/Text to Image (Qwen-Image).json @@ -1873,7 +1873,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image" + "category": "Image generation and editing/Text to image", + "description": "Generates images from text prompts using Qwen-Image, Alibaba's 20B MMDiT model with excellent multilingual text rendering." } ] }, diff --git a/blueprints/Text to Image (Z-Image-Turbo).json b/blueprints/Text to Image (Z-Image-Turbo).json index 6aa80e327..6975151ea 100644 --- a/blueprints/Text to Image (Z-Image-Turbo).json +++ b/blueprints/Text to Image (Z-Image-Turbo).json @@ -149,7 +149,7 @@ }, "revision": 0, "config": {}, - "name": "local-Text to Image (Z-Image-Turbo)", + "name": "Text to Image (Z-Image-Turbo)", "inputNode": { "id": -10, "bounding": [ @@ -1054,7 +1054,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image" + "category": "Image generation and editing/Text to image", + "description": "Generates images from text prompts using Z-Image-Turbo, Alibaba's distilled 6B DiT model." } ] }, @@ -1075,4 +1076,4 @@ } }, "version": 0.4 -} +} \ No newline at end of file diff --git a/blueprints/Text to Video (LTX-2.3).json b/blueprints/Text to Video (LTX-2.3).json index ff9bc6ccf..f44a216dd 100644 --- a/blueprints/Text to Video (LTX-2.3).json +++ b/blueprints/Text to Video (LTX-2.3).json @@ -4286,7 +4286,8 @@ "extra": { "workflowRendererVersion": "Vue-corrected" }, - "category": "Video generation and editing/Text to video" + "category": "Video generation and editing/Text to video", + "description": "Generates video from text prompts using LTX-2.3, Lightricks' video diffusion model." } ] }, diff --git a/blueprints/Text to Video (Wan 2.2).json b/blueprints/Text to Video (Wan 2.2).json index 0ce485b67..a264a490d 100644 --- a/blueprints/Text to Video (Wan 2.2).json +++ b/blueprints/Text to Video (Wan 2.2).json @@ -1572,7 +1572,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Text to video" + "category": "Video generation and editing/Text to video", + "description": "Generates video from text prompts using Wan2.2, Alibaba's diffusion video model." } ] }, @@ -1586,4 +1587,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} +} \ No newline at end of file diff --git a/blueprints/Unsharp Mask.json b/blueprints/Unsharp Mask.json index 137acaa43..79a4c954f 100644 --- a/blueprints/Unsharp Mask.json +++ b/blueprints/Unsharp Mask.json @@ -434,8 +434,9 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image Tools/Sharpen" + "category": "Image Tools/Sharpen", + "description": "Enhances edge contrast via unsharp masking for a sharper image appearance." } ] } -} +} \ No newline at end of file diff --git a/blueprints/Video Captioning (Gemini).json b/blueprints/Video Captioning (Gemini).json index ea6dc8bee..7642b23c1 100644 --- a/blueprints/Video Captioning (Gemini).json +++ b/blueprints/Video Captioning (Gemini).json @@ -307,7 +307,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Text generation/Video Captioning" + "category": "Text generation/Video Captioning", + "description": "Generates descriptive captions for video input using Google's Gemini multimodal LLM." } ] } diff --git a/blueprints/Video Inpaint(Wan2.1 VACE).json b/blueprints/Video Inpaint(Wan2.1 VACE).json index f404e6773..a658be5f8 100644 --- a/blueprints/Video Inpaint(Wan2.1 VACE).json +++ b/blueprints/Video Inpaint(Wan2.1 VACE).json @@ -165,7 +165,7 @@ }, "revision": 0, "config": {}, - "name": "local-Video Inpaint(Wan2.1 VACE)", + "name": "Video Inpaint (Wan 2.1 VACE)", "inputNode": { "id": -10, "bounding": [ @@ -2368,7 +2368,8 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Inpaint video" + "category": "Video generation and editing/Inpaint video", + "description": "Inpaints masked regions in video frames using Wan 2.1 VACE." } ] }, diff --git a/blueprints/Video Stitch.json b/blueprints/Video Stitch.json index 020896d78..6eb0f0bbf 100644 --- a/blueprints/Video Stitch.json +++ b/blueprints/Video Stitch.json @@ -584,8 +584,9 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video Tools/Stitch videos" + "category": "Video Tools/Stitch videos", + "description": "Stitches multiple video clips into a single sequential video file." } ] } -} +} \ No newline at end of file diff --git a/blueprints/Video Upscale(GAN x4).json b/blueprints/Video Upscale(GAN x4).json index b61dc88d7..73476e36b 100644 --- a/blueprints/Video Upscale(GAN x4).json +++ b/blueprints/Video Upscale(GAN x4).json @@ -412,9 +412,10 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Enhance video" + "category": "Video generation and editing/Enhance video", + "description": "Upscales video to 4× resolution using a GAN-based upscaling model." } ] }, "extra": {} -} +} \ No newline at end of file From 7bbf1e8169fa3080841b83914fa9901793b66b71 Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Sat, 9 May 2026 07:38:17 +0300 Subject: [PATCH 53/56] [Partner Nodes] Tripo3D 3.1 model (#13788) * feat(api-nodes): add Tripo3D 3.1 model Signed-off-by: bigcat88 * fix: price badges algo Signed-off-by: bigcat88 * [Partner Nodes] deprecate "quad" param for the TripoMultiviewToModel node Signed-off-by: bigcat88 --------- Signed-off-by: bigcat88 --- comfy_api_nodes/apis/tripo.py | 30 ++++-------- comfy_api_nodes/nodes_tripo.py | 84 +++++++++++----------------------- 2 files changed, 36 insertions(+), 78 deletions(-) diff --git a/comfy_api_nodes/apis/tripo.py b/comfy_api_nodes/apis/tripo.py index ffaaa7dc1..bce6b0e89 100644 --- a/comfy_api_nodes/apis/tripo.py +++ b/comfy_api_nodes/apis/tripo.py @@ -1,10 +1,11 @@ -from __future__ import annotations from enum import Enum -from typing import Optional, List, Dict, Any, Union +from typing import Optional, Any from pydantic import BaseModel, Field, RootModel + class TripoModelVersion(str, Enum): + v3_1_20260211 = 'v3.1-20260211' v3_0_20250812 = 'v3.0-20250812' v2_5_20250123 = 'v2.5-20250123' v2_0_20240919 = 'v2.0-20240919' @@ -142,7 +143,7 @@ class TripoFileEmptyReference(BaseModel): pass class TripoFileReference(RootModel): - root: Union[TripoFileTokenReference, TripoUrlReference, TripoObjectReference, TripoFileEmptyReference] + root: TripoFileTokenReference | TripoUrlReference | TripoObjectReference | TripoFileEmptyReference class TripoGetStsTokenRequest(BaseModel): format: str = Field(..., description='The format of the image') @@ -183,7 +184,7 @@ class TripoImageToModelRequest(BaseModel): class TripoMultiviewToModelRequest(BaseModel): type: TripoTaskType = TripoTaskType.MULTIVIEW_TO_MODEL - files: List[TripoFileReference] = Field(..., description='The file references to convert to a model') + files: list[TripoFileReference] = Field(..., description='The file references to convert to a model') model_version: Optional[TripoModelVersion] = Field(None, description='The model version to use for generation') orthographic_projection: Optional[bool] = Field(False, description='Whether to use orthographic projection') face_limit: Optional[int] = Field(None, description='The number of faces to limit the generation to') @@ -251,27 +252,13 @@ class TripoConvertModelRequest(BaseModel): with_animation: Optional[bool] = Field(None, description='Whether to include animations') pack_uv: Optional[bool] = Field(None, description='Whether to pack the UVs') bake: Optional[bool] = Field(None, description='Whether to bake the model') - part_names: Optional[List[str]] = Field(None, description='The names of the parts to include') + part_names: Optional[list[str]] = Field(None, description='The names of the parts to include') fbx_preset: Optional[TripoFbxPreset] = Field(None, description='The preset for the FBX export') export_vertex_colors: Optional[bool] = Field(None, description='Whether to export the vertex colors') export_orientation: Optional[TripoOrientation] = Field(None, description='The orientation for the export') animate_in_place: Optional[bool] = Field(None, description='Whether to animate in place') -class TripoTaskRequest(RootModel): - root: Union[ - TripoTextToModelRequest, - TripoImageToModelRequest, - TripoMultiviewToModelRequest, - TripoTextureModelRequest, - TripoRefineModelRequest, - TripoAnimatePrerigcheckRequest, - TripoAnimateRigRequest, - TripoAnimateRetargetRequest, - TripoStylizeModelRequest, - TripoConvertModelRequest - ] - class TripoTaskOutput(BaseModel): model: Optional[str] = Field(None, description='URL to the model') base_model: Optional[str] = Field(None, description='URL to the base model') @@ -283,12 +270,13 @@ class TripoTask(BaseModel): task_id: str = Field(..., description='The task ID') type: Optional[str] = Field(None, description='The type of task') status: Optional[TripoTaskStatus] = Field(None, description='The status of the task') - input: Optional[Dict[str, Any]] = Field(None, description='The input parameters for the task') + input: Optional[dict[str, Any]] = Field(None, description='The input parameters for the task') output: Optional[TripoTaskOutput] = Field(None, description='The output of the task') progress: Optional[int] = Field(None, description='The progress of the task', ge=0, le=100) create_time: Optional[int] = Field(None, description='The creation time of the task') running_left_time: Optional[int] = Field(None, description='The estimated time left for the task') queue_position: Optional[int] = Field(None, description='The position in the queue') + consumed_credit: int | None = Field(None) class TripoTaskResponse(BaseModel): code: int = Field(0, description='The response code') @@ -296,7 +284,7 @@ class TripoTaskResponse(BaseModel): class TripoGeneralResponse(BaseModel): code: int = Field(0, description='The response code') - data: Dict[str, str] = Field(..., description='The task ID data') + data: dict[str, str] = Field(..., description='The task ID data') class TripoBalanceData(BaseModel): balance: float = Field(..., description='The account balance') diff --git a/comfy_api_nodes/nodes_tripo.py b/comfy_api_nodes/nodes_tripo.py index 9f4298dce..d6501dee4 100644 --- a/comfy_api_nodes/nodes_tripo.py +++ b/comfy_api_nodes/nodes_tripo.py @@ -60,6 +60,7 @@ async def poll_until_finished( ], status_extractor=lambda x: x.data.status, progress_extractor=lambda x: x.data.progress, + price_extractor=lambda x: x.data.consumed_credit * 0.01 if x.data.consumed_credit else None, estimated_duration=average_duration, ) if response_poll.data.status == TripoTaskStatus.SUCCESS: @@ -113,7 +114,6 @@ class TripoTextToModelNode(IO.ComfyNode): depends_on=IO.PriceBadgeDepends( widgets=[ "model_version", - "style", "texture", "pbr", "quad", @@ -124,20 +124,17 @@ class TripoTextToModelNode(IO.ComfyNode): expr=""" ( $isV14 := $contains(widgets.model_version,"v1.4"); - $style := widgets.style; - $hasStyle := ($style != "" and $style != "none"); + $isV3OrLater := $contains(widgets.model_version,"v3."); $withTexture := widgets.texture or widgets.pbr; $isHdTexture := (widgets.texture_quality = "detailed"); $isDetailedGeometry := (widgets.geometry_quality = "detailed"); - $baseCredits := - $isV14 ? 20 : ($withTexture ? 20 : 10); - $credits := - $baseCredits - + ($hasStyle ? 5 : 0) + $credits := $isV14 ? 20 : ( + ($withTexture ? 20 : 10) + (widgets.quad ? 5 : 0) + ($isHdTexture ? 10 : 0) - + ($isDetailedGeometry ? 20 : 0); - {"type":"usd","usd": $round($credits * 0.01, 2)} + + (($isDetailedGeometry and $isV3OrLater) ? 20 : 0) + ); + {"type":"usd","usd": $round($credits * 0.01, 2), "format": {"approximate": true}} ) """, ), @@ -239,7 +236,6 @@ class TripoImageToModelNode(IO.ComfyNode): depends_on=IO.PriceBadgeDepends( widgets=[ "model_version", - "style", "texture", "pbr", "quad", @@ -250,20 +246,17 @@ class TripoImageToModelNode(IO.ComfyNode): expr=""" ( $isV14 := $contains(widgets.model_version,"v1.4"); - $style := widgets.style; - $hasStyle := ($style != "" and $style != "none"); + $isV3OrLater := $contains(widgets.model_version,"v3."); $withTexture := widgets.texture or widgets.pbr; $isHdTexture := (widgets.texture_quality = "detailed"); $isDetailedGeometry := (widgets.geometry_quality = "detailed"); - $baseCredits := - $isV14 ? 30 : ($withTexture ? 30 : 20); - $credits := - $baseCredits - + ($hasStyle ? 5 : 0) + $credits := $isV14 ? 30 : ( + ($withTexture ? 30 : 20) + (widgets.quad ? 5 : 0) + ($isHdTexture ? 10 : 0) - + ($isDetailedGeometry ? 20 : 0); - {"type":"usd","usd": $round($credits * 0.01, 2)} + + (($isDetailedGeometry and $isV3OrLater) ? 20 : 0) + ); + {"type":"usd","usd": $round($credits * 0.01, 2), "format": {"approximate": true}} ) """, ), @@ -358,7 +351,7 @@ class TripoMultiviewToModelNode(IO.ComfyNode): "texture_alignment", default="original_image", options=["original_image", "geometry"], optional=True, advanced=True ), IO.Int.Input("face_limit", default=-1, min=-1, max=500000, optional=True, advanced=True), - IO.Boolean.Input("quad", default=False, optional=True, advanced=True), + IO.Boolean.Input("quad", default=False, optional=True, advanced=True, tooltip="This parameter is deprecated and does nothing."), IO.Combo.Input("geometry_quality", default="standard", options=["standard", "detailed"], optional=True, advanced=True), ], outputs=[ @@ -379,7 +372,6 @@ class TripoMultiviewToModelNode(IO.ComfyNode): "model_version", "texture", "pbr", - "quad", "texture_quality", "geometry_quality", ], @@ -387,17 +379,16 @@ class TripoMultiviewToModelNode(IO.ComfyNode): expr=""" ( $isV14 := $contains(widgets.model_version,"v1.4"); + $isV3OrLater := $contains(widgets.model_version,"v3."); $withTexture := widgets.texture or widgets.pbr; $isHdTexture := (widgets.texture_quality = "detailed"); $isDetailedGeometry := (widgets.geometry_quality = "detailed"); - $baseCredits := - $isV14 ? 30 : ($withTexture ? 30 : 20); - $credits := - $baseCredits - + (widgets.quad ? 5 : 0) + $credits := $isV14 ? 30 : ( + ($withTexture ? 30 : 20) + ($isHdTexture ? 10 : 0) - + ($isDetailedGeometry ? 20 : 0); - {"type":"usd","usd": $round($credits * 0.01, 2)} + + (($isDetailedGeometry and $isV3OrLater) ? 20 : 0) + ); + {"type":"usd","usd": $round($credits * 0.01, 2), "format": {"approximate": true}} ) """, ), @@ -457,7 +448,7 @@ class TripoMultiviewToModelNode(IO.ComfyNode): geometry_quality=geometry_quality, texture_alignment=texture_alignment, face_limit=face_limit if face_limit != -1 else None, - quad=quad, + quad=None, ), ) return await poll_until_finished(cls, response, average_duration=80) @@ -498,7 +489,7 @@ class TripoTextureNode(IO.ComfyNode): expr=""" ( $tq := widgets.texture_quality; - {"type":"usd","usd": ($contains($tq,"detailed") ? 0.2 : 0.1)} + {"type":"usd","usd": ($contains($tq,"detailed") ? 0.2 : 0.1), "format": {"approximate": true}} ) """, ), @@ -555,7 +546,7 @@ class TripoRefineNode(IO.ComfyNode): is_api_node=True, is_output_node=True, price_badge=IO.PriceBadge( - expr="""{"type":"usd","usd":0.3}""", + expr="""{"type":"usd","usd":0.3, "format": {"approximate": true}}""", ), ) @@ -592,7 +583,7 @@ class TripoRigNode(IO.ComfyNode): is_api_node=True, is_output_node=True, price_badge=IO.PriceBadge( - expr="""{"type":"usd","usd":0.25}""", + expr="""{"type":"usd","usd":0.25, "format": {"approximate": true}}""", ), ) @@ -652,7 +643,7 @@ class TripoRetargetNode(IO.ComfyNode): is_api_node=True, is_output_node=True, price_badge=IO.PriceBadge( - expr="""{"type":"usd","usd":0.1}""", + expr="""{"type":"usd","usd":0.1, "format": {"approximate": true}}""", ), ) @@ -761,19 +752,10 @@ class TripoConversionNode(IO.ComfyNode): "face_limit", "texture_size", "texture_format", - "force_symmetry", "flatten_bottom", "flatten_bottom_threshold", "pivot_to_center_bottom", "scale_factor", - "with_animation", - "pack_uv", - "bake", - "part_names", - "fbx_preset", - "export_vertex_colors", - "export_orientation", - "animate_in_place", ], ), expr=""" @@ -783,28 +765,16 @@ class TripoConversionNode(IO.ComfyNode): $flatThresh := (widgets.flatten_bottom_threshold != null) ? widgets.flatten_bottom_threshold : 0; $scale := (widgets.scale_factor != null) ? widgets.scale_factor : 1; $texFmt := (widgets.texture_format != "" ? widgets.texture_format : "jpeg"); - $part := widgets.part_names; - $fbx := (widgets.fbx_preset != "" ? widgets.fbx_preset : "blender"); - $orient := (widgets.export_orientation != "" ? widgets.export_orientation : "default"); $advanced := widgets.quad or - widgets.force_symmetry or widgets.flatten_bottom or widgets.pivot_to_center_bottom or - widgets.with_animation or - widgets.pack_uv or - widgets.bake or - widgets.export_vertex_colors or - widgets.animate_in_place or ($face != -1) or ($texSize != 4096) or ($flatThresh != 0) or ($scale != 1) or - ($texFmt != "jpeg") or - ($part != "") or - ($fbx != "blender") or - ($orient != "default"); - {"type":"usd","usd": ($advanced ? 0.1 : 0.05)} + ($texFmt != "jpeg"); + {"type":"usd","usd": ($advanced ? 0.1 : 0.05), "format": {"approximate": true}} ) """, ), From a4b7e3beedda4180cd6a2b319c9805990357ee96 Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Sat, 9 May 2026 23:53:10 +0900 Subject: [PATCH 54/56] Bump comfyui-frontend-package to 1.43.18 (#13809) Co-authored-by: github-actions[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5c7ff76be..6fd808772 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -comfyui-frontend-package==1.43.17 +comfyui-frontend-package==1.43.18 comfyui-workflow-templates==0.9.72 comfyui-embedded-docs==0.4.4 torch From 3200f28e3a8663f18b9a9568472ad912ea5c6396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jukka=20Sepp=C3=A4nen?= <40791699+kijai@users.noreply.github.com> Date: Sun, 10 May 2026 00:02:56 +0300 Subject: [PATCH 55/56] Support Wan-Dancer (#13813) * initial WanDancer support * nodes_wandancer: Add list form of chunker. Create an alternate list form of the node so the chunk gens can be trivially looped by the comfy executor. * Closer match to original soxr resampling * Remove librosa node * Cleanup --------- Co-authored-by: Rattus --- comfy/ldm/wan/model.py | 15 +- comfy/ldm/wan/model_wandancer.py | 251 ++++++++ comfy/model_base.py | 25 + comfy/model_detection.py | 2 + comfy/supported_models.py | 32 + comfy_extras/nodes_wandancer.py | 1002 ++++++++++++++++++++++++++++++ nodes.py | 1 + 7 files changed, 1322 insertions(+), 6 deletions(-) create mode 100644 comfy/ldm/wan/model_wandancer.py create mode 100644 comfy_extras/nodes_wandancer.py diff --git a/comfy/ldm/wan/model.py b/comfy/ldm/wan/model.py index b2287dba9..70dfe7b16 100644 --- a/comfy/ldm/wan/model.py +++ b/comfy/ldm/wan/model.py @@ -1135,7 +1135,7 @@ class AudioInjector_WAN(nn.Module): self.injector_adain_output_layers = nn.ModuleList( [operations.Linear(dim, dim, dtype=dtype, device=device) for _ in range(audio_injector_id)]) - def forward(self, x, block_id, audio_emb, audio_emb_global, seq_len): + def forward(self, x, block_id, audio_emb, audio_emb_global, seq_len, scale=1.0): audio_attn_id = self.injected_block_id.get(block_id, None) if audio_attn_id is None: return x @@ -1148,12 +1148,15 @@ class AudioInjector_WAN(nn.Module): attn_hidden_states = adain_hidden_states else: attn_hidden_states = self.injector_pre_norm_feat[audio_attn_id](input_hidden_states) - audio_emb = rearrange(audio_emb, "b t n c -> (b t) n c", t=num_frames) - attn_audio_emb = audio_emb + + if audio_emb.dim() == 3: # WanDancer case + attn_audio_emb = rearrange(audio_emb, "b t c -> (b t) 1 c", t=num_frames) + else: # S2V case + attn_audio_emb = rearrange(audio_emb, "b t n c -> (b t) n c", t=num_frames) + residual_out = self.injector[audio_attn_id](x=attn_hidden_states, context=attn_audio_emb) - residual_out = rearrange( - residual_out, "(b t) n c -> b (t n) c", t=num_frames) - x[:, :seq_len] = x[:, :seq_len] + residual_out + residual_out = rearrange(residual_out, "(b t) n c -> b (t n) c", t=num_frames) + x[:, :seq_len] = x[:, :seq_len] + residual_out * scale return x diff --git a/comfy/ldm/wan/model_wandancer.py b/comfy/ldm/wan/model_wandancer.py new file mode 100644 index 000000000..3caef6dc5 --- /dev/null +++ b/comfy/ldm/wan/model_wandancer.py @@ -0,0 +1,251 @@ +import torch +import torch.nn as nn +import comfy +from comfy.ldm.modules.attention import optimized_attention +from comfy.ldm.flux.math import apply_rope1 +from comfy.ldm.flux.layers import EmbedND + +from .model import AudioInjector_WAN, WanModel, MLPProj, Head, sinusoidal_embedding_1d + + +class MusicSelfAttention(nn.Module): + def __init__(self, dim, num_heads, device=None, dtype=None, operations=None): + assert dim % num_heads == 0 + super().__init__() + self.embed_dim = dim + self.num_heads = num_heads + self.head_dim = dim // num_heads + + self.q_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + self.k_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + self.v_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + self.out_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + + def forward(self, x, freqs): + b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim + + q = self.q_proj(x).view(b, s, n, d) + q = apply_rope1(q, freqs) + + k = self.k_proj(x).view(b, s, n, d) + k = apply_rope1(k, freqs) + + x = optimized_attention( + q.view(b, s, n * d), + k.view(b, s, n * d), + self.v_proj(x).view(b, s, n * d), + heads=self.num_heads, + ) + + return self.out_proj(x) + + +class MusicEncoderLayer(nn.Module): + def __init__(self, dim: int, num_heads: int, ffn_dim: int, device=None, dtype=None, operations=None): + super().__init__() + self.self_attn = MusicSelfAttention(dim, num_heads, device=device, dtype=dtype, operations=operations) + + self.linear1 = operations.Linear(dim, ffn_dim, device=device, dtype=dtype) + self.linear2 = operations.Linear(ffn_dim, dim, device=device, dtype=dtype) + + self.norm1 = operations.LayerNorm(dim, device=device, dtype=dtype) + self.norm2 = operations.LayerNorm(dim, device=device, dtype=dtype) + + def forward(self, x: torch.Tensor, freqs: torch.Tensor) -> torch.Tensor: + x = x + self.self_attn(self.norm1(x), freqs=freqs) + x = x + self.linear2(torch.nn.functional.gelu(self.linear1(self.norm2(x)))) # ffn + return x + + +class WanDancerModel(WanModel): + def __init__(self, + model_type='wandancer', + patch_size=(1, 2, 2), + text_len=512, + in_dim=16, + dim=5120, + ffn_dim=8192, + freq_dim=256, + text_dim=4096, + out_dim=16, + num_heads=16, + num_layers=40, + window_size=(-1, -1), + qk_norm=True, + cross_attn_norm=True, + eps=1e-6, + in_dim_ref_conv=None, + image_model=None, + device=None, dtype=None, operations=None, + audio_inject_layers=[0, 4, 8, 12, 16, 20, 24, 27], + music_dim = 256, + music_heads = 4, + music_feature_dim = 35, + music_latent_dim = 256 + ): + + super().__init__(model_type='i2v', patch_size=patch_size, text_len=text_len, in_dim=in_dim, dim=dim, ffn_dim=ffn_dim, freq_dim=freq_dim, text_dim=text_dim, out_dim=out_dim, + num_heads=num_heads, num_layers=num_layers, window_size=window_size, qk_norm=qk_norm, cross_attn_norm=cross_attn_norm, eps=eps, image_model=image_model, in_dim_ref_conv=in_dim_ref_conv, + device=device, dtype=dtype, operations=operations) + + self.dtype = dtype + operation_settings = {"operations": operations, "device": device, "dtype": dtype} + + self.patch_embedding_global = operations.Conv3d(in_dim, dim, kernel_size=patch_size, stride=patch_size, device=operation_settings.get("device"), dtype=torch.float32) + self.img_emb_refimage = MLPProj(1280, dim, operation_settings=operation_settings) + self.head_global = Head(dim, out_dim, patch_size, eps, operation_settings=operation_settings) + + self.music_injector = AudioInjector_WAN( + dim=self.dim, + num_heads=self.num_heads, + inject_layer=audio_inject_layers, + root_net=self, + enable_adain=False, + dtype=dtype, device=device, operations=operations + ) + + self.music_projection = operations.Linear(music_feature_dim, music_latent_dim, device=device, dtype=dtype) + self.music_encoder = nn.ModuleList([MusicEncoderLayer(dim=music_dim, num_heads=music_heads, ffn_dim=1024, device=device, dtype=dtype, operations=operations) for _ in range(2)]) + music_head_dim = music_dim // music_heads + self.music_rope_embedder = EmbedND(dim=music_head_dim, theta=10000.0, axes_dim=[music_head_dim]) + + def forward_orig(self, x, t, context, clip_fea=None, clip_fea_ref=None, freqs=None, audio_embed=None, fps=30, audio_inject_scale=1.0, transformer_options={}, **kwargs): + # embeddings + if int(fps + 0.5) != 30: + x = self.patch_embedding_global(x.float()).to(x.dtype) + else: + x = self.patch_embedding(x.float()).to(x.dtype) + + grid_sizes = x.shape[2:] + latent_frames = grid_sizes[0] + transformer_options["grid_sizes"] = grid_sizes + x = x.flatten(2).transpose(1, 2) + seq_len = x.size(1) + + # time embeddings + e = self.time_embedding(sinusoidal_embedding_1d(self.freq_dim, t.flatten()).to(dtype=x[0].dtype)) + e = e.reshape(t.shape[0], -1, e.shape[-1]) + e0 = self.time_projection(e).unflatten(2, (6, self.dim)) + + full_ref = None + if self.ref_conv is not None: # model has the weight, but this wasn't used in the original pipeline + full_ref = kwargs.get("reference_latent", None) + if full_ref is not None: + full_ref = self.ref_conv(full_ref).flatten(2).transpose(1, 2) + x = torch.concat((full_ref, x), dim=1) + + # context + context = self.text_embedding(context) + + audio_emb = None + if audio_embed is not None: # encode music feature,[1, frame_num, 35] -> [1, F*8, dim] + music_feature = self.music_projection(audio_embed) + + music_seq_len = music_feature.shape[1] + music_ids = torch.arange(music_seq_len, device=music_feature.device, dtype=music_feature.dtype).reshape(1, -1, 1) # create 1D position IDs + music_freqs = self.music_rope_embedder(music_ids).movedim(1, 2) + + # apply encoder layers + for layer in self.music_encoder: + music_feature = layer(music_feature, music_freqs) + + # interpolate + audio_emb = torch.nn.functional.interpolate(music_feature.unsqueeze(1), size=(latent_frames * 8, self.dim), mode='bilinear').squeeze(1) + + context_img_len = 0 + if self.img_emb is not None and clip_fea is not None: + context_clip = self.img_emb(clip_fea) # bs x 257 x dim + context = torch.cat([context_clip, context], dim=1) + context_img_len += clip_fea.shape[-2] + if self.img_emb_refimage is not None and clip_fea_ref is not None: + context_clip_ref = self.img_emb_refimage(clip_fea_ref) + context = torch.cat([context_clip_ref, context], dim=1) + context_img_len += clip_fea_ref.shape[-2] + + patches_replace = transformer_options.get("patches_replace", {}) + blocks_replace = patches_replace.get("dit", {}) + transformer_options["total_blocks"] = len(self.blocks) + transformer_options["block_type"] = "double" + for i, block in enumerate(self.blocks): + transformer_options["block_index"] = i + if ("double_block", i) in blocks_replace: + def block_wrap(args): + out = {} + out["img"] = block(args["img"], context=args["txt"], e=args["vec"], freqs=args["pe"], context_img_len=context_img_len, transformer_options=args["transformer_options"]) + return out + out = blocks_replace[("double_block", i)]({"img": x, "txt": context, "vec": e0, "pe": freqs, "transformer_options": transformer_options}, {"original_block": block_wrap}) + x = out["img"] + else: + x = block(x, e=e0, freqs=freqs, context=context, context_img_len=context_img_len, transformer_options=transformer_options) + if audio_emb is not None: + x = self.music_injector(x, i, audio_emb, audio_emb_global=None, seq_len=seq_len, scale=audio_inject_scale) + + # head + if int(fps + 0.5) != 30: + x = self.head_global(x, e) + else: + x = self.head(x, e) + + if full_ref is not None: + x = x[:, full_ref.shape[1]:] + + # unpatchify + x = self.unpatchify(x, grid_sizes) + return x + + def _forward(self, x, timestep, context, clip_fea=None, time_dim_concat=None, transformer_options={}, clip_fea_ref=None, fps=30, audio_inject_scale=1.0, **kwargs): + bs, c, t, h, w = x.shape + x = comfy.ldm.common_dit.pad_to_patch_size(x, self.patch_size) + + t_len = t + if time_dim_concat is not None: + time_dim_concat = comfy.ldm.common_dit.pad_to_patch_size(time_dim_concat, self.patch_size) + x = torch.cat([x, time_dim_concat], dim=2) + t_len = x.shape[2] + + freqs = self.rope_encode(t_len, h, w, device=x.device, dtype=x.dtype, fps=fps, transformer_options=transformer_options) + return self.forward_orig(x, timestep, context, clip_fea=clip_fea, clip_fea_ref=clip_fea_ref, freqs=freqs, fps=fps, audio_inject_scale=audio_inject_scale, transformer_options=transformer_options, **kwargs)[:, :, :t, :h, :w] + + def rope_encode(self, t, h, w, t_start=0, steps_t=None, steps_h=None, steps_w=None, fps=30, device=None, dtype=None, transformer_options={}): + patch_size = self.patch_size + t_len = ((t + (patch_size[0] // 2)) // patch_size[0]) + h_len = ((h + (patch_size[1] // 2)) // patch_size[1]) + w_len = ((w + (patch_size[2] // 2)) // patch_size[2]) + + if steps_t is None: + steps_t = t_len + if steps_h is None: + steps_h = h_len + if steps_w is None: + steps_w = w_len + + h_start = 0 + w_start = 0 + rope_options = transformer_options.get("rope_options", None) + if rope_options is not None: + t_len = (t_len - 1.0) * rope_options.get("scale_t", 1.0) + 1.0 + h_len = (h_len - 1.0) * rope_options.get("scale_y", 1.0) + 1.0 + w_len = (w_len - 1.0) * rope_options.get("scale_x", 1.0) + 1.0 + + t_start += rope_options.get("shift_t", 0.0) + h_start += rope_options.get("shift_y", 0.0) + w_start += rope_options.get("shift_x", 0.0) + + img_ids = torch.zeros((steps_t, steps_h, steps_w, 3), device=device, dtype=dtype) + + if int(fps + 0.5) != 30: + time_scale = 30.0 / fps # how many time units each frame represents relative to 30fps + positions_new = torch.arange(steps_t, device=device, dtype=dtype) * time_scale + t_start + total_frames_at_30fps = int(time_scale * steps_t + 0.5) + positions_new[-1] = t_start + (total_frames_at_30fps - 1) + + img_ids[:, :, :, 0] = img_ids[:, :, :, 0] + positions_new.reshape(-1, 1, 1) + else: + img_ids[:, :, :, 0] = img_ids[:, :, :, 0] + torch.linspace(t_start, t_start + (t_len - 1), steps=steps_t, device=device, dtype=dtype).reshape(-1, 1, 1) + + img_ids[:, :, :, 1] = img_ids[:, :, :, 1] + torch.linspace(h_start, h_start + (h_len - 1), steps=steps_h, device=device, dtype=dtype).reshape(1, -1, 1) + img_ids[:, :, :, 2] = img_ids[:, :, :, 2] + torch.linspace(w_start, w_start + (w_len - 1), steps=steps_w, device=device, dtype=dtype).reshape(1, 1, -1) + img_ids = img_ids.reshape(1, -1, img_ids.shape[-1]) + + freqs = self.rope_embedder(img_ids).movedim(1, 2) + return freqs diff --git a/comfy/model_base.py b/comfy/model_base.py index 57a1e44d2..dbed239e5 100644 --- a/comfy/model_base.py +++ b/comfy/model_base.py @@ -43,6 +43,7 @@ import comfy.ldm.lumina.model import comfy.ldm.wan.model import comfy.ldm.wan.model_animate import comfy.ldm.wan.ar_model +import comfy.ldm.wan.model_wandancer import comfy.ldm.hunyuan3d.model import comfy.ldm.hidream.model import comfy.ldm.chroma.model @@ -1599,6 +1600,30 @@ class WAN21_SCAIL(WAN21): return out +class WAN22_WanDancer(WAN21): + def __init__(self, model_config, model_type=ModelType.FLOW, image_to_video=True, device=None): + super(WAN21, self).__init__(model_config, model_type, device=device, unet_model=comfy.ldm.wan.model_wandancer.WanDancerModel) + self.image_to_video = image_to_video + + def extra_conds(self, **kwargs): + out = super().extra_conds(**kwargs) + audio_embed = kwargs.get("audio_embed", None) + if audio_embed is not None: + out['audio_embed'] = comfy.conds.CONDRegular(audio_embed) + + clip_vision_output_ref = kwargs.get("clip_vision_output_ref", None) + if clip_vision_output_ref is not None: + out['clip_fea_ref'] = comfy.conds.CONDRegular(clip_vision_output_ref.penultimate_hidden_states) + + fps = kwargs.get("fps", None) + if fps is not None: + out['fps'] = comfy.conds.CONDRegular(torch.FloatTensor([fps])) + + audio_inject_scale = kwargs.get("audio_inject_scale", None) + if audio_inject_scale is not None: + out['audio_inject_scale'] = comfy.conds.CONDRegular(torch.FloatTensor([audio_inject_scale])) + return out + class Hunyuan3Dv2(BaseModel): def __init__(self, model_config, model_type=ModelType.FLOW, device=None): super().__init__(model_config, model_type, device=device, unet_model=comfy.ldm.hunyuan3d.model.Hunyuan3Dv2) diff --git a/comfy/model_detection.py b/comfy/model_detection.py index d9b67dcdf..8ae456481 100644 --- a/comfy/model_detection.py +++ b/comfy/model_detection.py @@ -572,6 +572,8 @@ def detect_unet_config(state_dict, key_prefix, metadata=None): dit_config["model_type"] = "animate" elif '{}patch_embedding_pose.weight'.format(key_prefix) in state_dict_keys: dit_config["model_type"] = "scail" + elif '{}patch_embedding_global.weight'.format(key_prefix) in state_dict_keys: + dit_config["model_type"] = "wandancer" else: if '{}img_emb.proj.0.bias'.format(key_prefix) in state_dict_keys: dit_config["model_type"] = "i2v" diff --git a/comfy/supported_models.py b/comfy/supported_models.py index 6a9613602..40417f922 100644 --- a/comfy/supported_models.py +++ b/comfy/supported_models.py @@ -1313,6 +1313,37 @@ class WAN21_SCAIL(WAN21_T2V): out = model_base.WAN21_SCAIL(self, image_to_video=False, device=device) return out +class WAN22_WanDancer(WAN21_T2V): + unet_config = { + "image_model": "wan2.1", + "model_type": "wandancer", + "in_dim": 36, + } + + def __init__(self, unet_config): + super().__init__(unet_config) + self.memory_usage_factor = 1.8 + + def get_model(self, state_dict, prefix="", device=None): + out = model_base.WAN22_WanDancer(self, image_to_video=True, device=device) + return out + + def process_unet_state_dict(self, state_dict): + out_sd = {} + for k in list(state_dict.keys()): + # split music_encoder in_proj into q_proj, k_proj, v_proj + if "music_encoder" in k and "self_attn.in_proj" in k: + suffix = "weight" if k.endswith("weight") else "bias" + tensor = state_dict[k] + d = tensor.shape[0] // 3 + prefix = k.replace(f"in_proj_{suffix}", "") + out_sd[f"{prefix}q_proj.{suffix}"] = tensor[:d] + out_sd[f"{prefix}k_proj.{suffix}"] = tensor[d:2*d] + out_sd[f"{prefix}v_proj.{suffix}"] = tensor[2*d:] + else: + out_sd[k] = state_dict[k] + return out_sd + class Hunyuan3Dv2(supported_models_base.BASE): unet_config = { "image_model": "hunyuan3d2", @@ -1982,6 +2013,7 @@ models = [ WAN22_Animate, WAN21_FlowRVS, WAN21_SCAIL, + WAN22_WanDancer, Hunyuan3Dv2mini, Hunyuan3Dv2, Hunyuan3Dv2_1, diff --git a/comfy_extras/nodes_wandancer.py b/comfy_extras/nodes_wandancer.py new file mode 100644 index 000000000..faaeb9020 --- /dev/null +++ b/comfy_extras/nodes_wandancer.py @@ -0,0 +1,1002 @@ +import math +import nodes +import node_helpers +import torch +import torchaudio +import comfy.model_management +import comfy.utils +import numpy as np +import logging +from typing_extensions import override +from comfy_api.latest import ComfyExtension, io + +import scipy.signal +import scipy.ndimage +import scipy.fft +import scipy.sparse + +# Audio Processing Functions - Derived from librosa (https://github.com/librosa/librosa) +# Copyright (c) 2013--2023, librosa development team. + +def mel_to_hz(mels, htk=False): + """Convert mel to Hz (slaney)""" + mels = np.asanyarray(mels) + if htk: + return 700.0 * (10.0 ** (mels / 2595.0) - 1.0) + f_min = 0.0 + f_sp = 200.0 / 3 + freqs = f_min + f_sp * mels + min_log_hz = 1000.0 + min_log_mel = (min_log_hz - f_min) / f_sp + logstep = np.log(6.4) / 27.0 + if mels.ndim: + log_t = mels >= min_log_mel + freqs[log_t] = min_log_hz * np.exp(logstep * (mels[log_t] - min_log_mel)) + elif mels >= min_log_mel: + freqs = min_log_hz * np.exp(logstep * (mels - min_log_mel)) + return freqs + +def hz_to_mel(frequencies, htk=False): + """Convert Hz to mel (slaney)""" + frequencies = np.asanyarray(frequencies) + if htk: + return 2595.0 * np.log10(1.0 + frequencies / 700.0) + f_min = 0.0 + f_sp = 200.0 / 3 + mels = (frequencies - f_min) / f_sp + min_log_hz = 1000.0 + min_log_mel = (min_log_hz - f_min) / f_sp + logstep = np.log(6.4) / 27.0 + if frequencies.ndim: + log_t = frequencies >= min_log_hz + mels[log_t] = min_log_mel + np.log(frequencies[log_t] / min_log_hz) / logstep + elif frequencies >= min_log_hz: + mels = min_log_mel + np.log(frequencies / min_log_hz) / logstep + return mels + +def compute_cqt(y, sr=22050, hop_length=512, fmin=None, n_bins=84, bins_per_octave=12, tuning=0.0): + """Compute Constant-Q Transform (CQT) spectrogram.""" + + def _relative_bandwidth(freqs): + bpo = np.empty_like(freqs) + logf = np.log2(freqs) + bpo[0] = 1.0 / (logf[1] - logf[0]) + bpo[-1] = 1.0 / (logf[-1] - logf[-2]) + bpo[1:-1] = 2.0 / (logf[2:] - logf[:-2]) + return (2.0 ** (2.0 / bpo) - 1.0) / (2.0 ** (2.0 / bpo) + 1.0) + + def _wavelet_lengths(freqs, sr, filter_scale, alpha): + Q = float(filter_scale) / alpha + return Q * sr / freqs # shape (n_bins,) floats + + def _build_wavelet(freqs_oct, sr, filter_scale, alpha_oct): + lengths = _wavelet_lengths(freqs_oct, sr, filter_scale, alpha_oct) + filters = [] + for ilen, freq in zip(lengths, freqs_oct): + t = np.arange(int(-ilen // 2), int(ilen // 2), dtype=float) + sig = (np.cos(t * 2 * np.pi * freq / sr) + + 1j * np.sin(t * 2 * np.pi * freq / sr)).astype(np.complex64) + sig *= scipy.signal.get_window('hann', len(sig), fftbins=True) + l1 = np.sum(np.abs(sig)) + tiny = np.finfo(np.float32).tiny + sig /= max(l1, tiny) + filters.append(sig) + max_len = max(lengths) + n_fft = int(2.0 ** np.ceil(np.log2(max_len))) + out = np.zeros((len(filters), n_fft), dtype=np.complex64) + for k, f in enumerate(filters): + lpad = int((n_fft - len(f)) // 2) + out[k, lpad: lpad + len(f)] = f + return out, lengths + + def _resample_half(y): + ratio = 0.5 + n_samples = int(np.ceil(len(y) * ratio)) + # Kaiser-windowed FIR matches librosa/soxr more closely than scipy's default Hamming filter + L = 2 + h = scipy.signal.firwin(160 * L + 1, 0.96 / L, window=('kaiser', 6.5)) + y_hat = scipy.signal.resample_poly(y.astype(np.float32), 1, 2, window=h) + if len(y_hat) > n_samples: + y_hat = y_hat[:n_samples] + elif len(y_hat) < n_samples: + y_hat = np.pad(y_hat, (0, n_samples - len(y_hat))) + y_hat /= np.sqrt(ratio) + return y_hat.astype(np.float32) + + def _sparsify_rows(x, quantile=0.01): + mags = np.abs(x) + norms = np.sum(mags, axis=1, keepdims=True) + norms = np.where(norms == 0, 1.0, norms) + mag_sort = np.sort(mags, axis=1) + cumulative_mag = np.cumsum(mag_sort / norms, axis=1) + threshold_idx = np.argmin(cumulative_mag < quantile, axis=1) + x_sparse = scipy.sparse.lil_matrix(x.shape, dtype=x.dtype) + for i, j in enumerate(threshold_idx): + idx = np.where(mags[i] >= mag_sort[i, j]) + x_sparse[i, idx] = x[i, idx] + return x_sparse.tocsr() + + if fmin is None: + fmin = 32.70319566257483 # C1 note frequency + + fmin = fmin * (2.0 ** (tuning / bins_per_octave)) + freqs = fmin * (2.0 ** (np.arange(n_bins) / bins_per_octave)) + + alpha = _relative_bandwidth(freqs) + lengths = _wavelet_lengths(freqs, float(sr), 1, alpha) + + n_octaves = int(np.ceil(float(n_bins) / bins_per_octave)) + n_filters = min(bins_per_octave, n_bins) + + cqt_resp = [] + my_y = y.astype(np.float32) + my_sr = float(sr) + my_hop = int(hop_length) + + for i in range(n_octaves): + if i == 0: + sl = slice(-n_filters, None) + else: + sl = slice(-n_filters * (i + 1), -n_filters * i) + + freqs_oct = freqs[sl] + alpha_oct = alpha[sl] + + basis, basis_lengths = _build_wavelet(freqs_oct, my_sr, 1, alpha_oct) + n_fft_oct = basis.shape[1] + + # Frequency-domain normalisation + basis = basis.astype(np.complex64) + basis *= basis_lengths[:, np.newaxis] / float(n_fft_oct) + fft_basis = scipy.fft.fft(basis, n=n_fft_oct, axis=1)[:, :(n_fft_oct // 2) + 1] + fft_basis = _sparsify_rows(fft_basis, quantile=0.01) + fft_basis = fft_basis * np.sqrt(sr / my_sr) + + y_pad = np.pad(my_y, int(n_fft_oct // 2), mode='constant') + n_frames = 1 + (len(y_pad) - n_fft_oct) // my_hop + frames = np.lib.stride_tricks.as_strided( + y_pad, + shape=(n_fft_oct, n_frames), + strides=(y_pad.strides[0], y_pad.strides[0] * my_hop), + ) + stft_result = scipy.fft.rfft(frames, axis=0) + cqt_resp.append(fft_basis.dot(stft_result)) + + if my_hop % 2 == 0: + my_hop //= 2 + my_sr /= 2.0 + my_y = _resample_half(my_y) + + max_col = min(c.shape[-1] for c in cqt_resp) + cqt_out = np.empty((n_bins, max_col), dtype=np.complex64) + end = n_bins + for c_i in cqt_resp: + n_oct = c_i.shape[0] + if end < n_oct: + cqt_out[:end, :] = c_i[-end:, :max_col] + else: + cqt_out[end - n_oct:end, :] = c_i[:, :max_col] + end -= n_oct + + cqt_out /= np.sqrt(lengths)[:, np.newaxis] + return np.abs(cqt_out).astype(np.float32) + + +def cq_to_chroma_mapping(n_input, bins_per_octave=12, n_chroma=12, fmin=None): + """Map CQT bins to chroma bins.""" + + if fmin is None: + fmin = 32.70319566257483 # C1 note frequency + + n_merge = bins_per_octave / n_chroma + cq_to_ch = np.repeat(np.eye(n_chroma), int(n_merge), axis=1) + cq_to_ch = np.roll(cq_to_ch, -int(n_merge // 2), axis=1) + n_octaves = int(np.ceil(n_input / bins_per_octave)) + cq_to_ch = np.tile(cq_to_ch, n_octaves)[:, :n_input] + + midi_0 = np.mod(12 * np.log2(fmin / 440.0) + 69, 12) + roll = int(np.round(midi_0 * (n_chroma / 12.0))) + cq_to_ch = np.roll(cq_to_ch, roll, axis=0) + + return cq_to_ch.astype(np.float32) + + +def _parabolic_interpolation(S, axis=-2): + """Compute parabolic interpolation shift for peak refinement.""" + S_next = np.roll(S, -1, axis=axis) + S_prev = np.roll(S, 1, axis=axis) + + a = S_next + S_prev - 2 * S + b = (S_next - S_prev) / 2.0 + + shifts = np.zeros_like(S) + valid = np.abs(b) < np.abs(a) + shifts[valid] = -b[valid] / a[valid] + + if axis == -2 or axis == S.ndim - 2: + shifts[0, :] = 0 + shifts[-1, :] = 0 + elif axis == 0: + shifts[0, ...] = 0 + shifts[-1, ...] = 0 + + return shifts + + +def _localmax(S, axis=-2): + """Find local maxima along an axis.""" + + S_prev = np.roll(S, 1, axis=axis) + S_next = np.roll(S, -1, axis=axis) + + local_max = (S > S_prev) & (S >= S_next) + + if axis == -2 or axis == S.ndim - 2: + local_max[-1, :] = S[-1, :] > S[-2, :] + # First element is never a local max (strict inequality with previous) + local_max[0, :] = False + elif axis == 0: + local_max[-1, ...] = S[-1, ...] > S[-2, ...] + local_max[0, ...] = False + + return local_max + + +def piptrack(y=None, sr=22050, S=None, n_fft=2048, hop_length=512, + fmin=150.0, fmax=4000.0, threshold=0.1): + """Pitch tracking on thresholded parabolically-interpolated STFT.""" + + # Compute STFT if not provided + if S is None: + if y is None: + raise ValueError("Either y or S must be provided") + + fft_window = scipy.signal.get_window('hann', n_fft, fftbins=True) + if len(fft_window) < n_fft: + lpad = int((n_fft - len(fft_window)) // 2) + fft_window = np.pad(fft_window, (lpad, int(n_fft - len(fft_window) - lpad)), mode='constant') + fft_window = fft_window.reshape((-1, 1)) + + y_pad = np.pad(y, int(n_fft // 2), mode='constant') + n_frames = 1 + (len(y_pad) - n_fft) // hop_length + frames = np.lib.stride_tricks.as_strided( + y_pad, + shape=(n_fft, n_frames), + strides=(y_pad.strides[0], y_pad.strides[0] * hop_length) + ) + + S = scipy.fft.rfft((fft_window * frames).astype(np.float32), axis=0) + + S = np.abs(S) + + fmin = max(fmin, 0) + fmax = min(fmax, float(sr) / 2) + + fft_freqs = np.fft.rfftfreq(S.shape[0] * 2 - 2, 1.0 / sr) + if len(fft_freqs) > S.shape[0]: + fft_freqs = fft_freqs[:S.shape[0]] + + shift = _parabolic_interpolation(S, axis=0) + avg = np.gradient(S, axis=0) + dskew = 0.5 * avg * shift + + pitches = np.zeros_like(S) + mags = np.zeros_like(S) + + freq_mask = (fmin <= fft_freqs) & (fft_freqs < fmax) + freq_mask = freq_mask.reshape(-1, 1) + + ref_value = threshold * np.max(S, axis=0, keepdims=True) + local_max = _localmax(S * (S > ref_value), axis=0) + idx = np.nonzero(freq_mask & local_max) + + pitches[idx] = (idx[0] + shift[idx]) * float(sr) / (S.shape[0] * 2 - 2) + mags[idx] = S[idx] + dskew[idx] + + return pitches, mags + + +def hz_to_octs(frequencies, tuning=0.0, bins_per_octave=12): + """Convert frequencies (Hz) to octave numbers.""" + + A440 = 440.0 * 2.0 ** (tuning / bins_per_octave) + octs = np.log2(np.asanyarray(frequencies) / (float(A440) / 16)) + return octs + + +def pitch_tuning(frequencies, resolution=0.01, bins_per_octave=12): + """Estimate tuning offset from a collection of pitches.""" + + frequencies = np.atleast_1d(frequencies) + frequencies = frequencies[frequencies > 0] + + if not np.any(frequencies): + return 0.0 + + residual = np.mod(bins_per_octave * hz_to_octs(frequencies, tuning=0.0, + bins_per_octave=bins_per_octave), 1.0) + residual[residual >= 0.5] -= 1.0 + + bins = np.linspace(-0.5, 0.5, int(np.ceil(1.0 / resolution)) + 1) + counts, tuning = np.histogram(residual, bins) + tuning_est = tuning[np.argmax(counts)] + return tuning_est + + +def estimate_tuning(y, sr=22050, bins_per_octave=12): + """Estimate global tuning deviation from 12-TET.""" + n_fft = 2048 + hop_length = 512 + + if len(y) < n_fft: + return 0.0 + + pitch, mag = piptrack(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length, + fmin=150.0, fmax=4000.0, threshold=0.1) + + pitch_mask = pitch > 0 + + if not pitch_mask.any(): + return 0.0 + + threshold = np.median(mag[pitch_mask]) + valid_pitches = pitch[(mag >= threshold) & pitch_mask] + + if len(valid_pitches) == 0: + return 0.0 + + tuning = pitch_tuning(valid_pitches, resolution=0.01, bins_per_octave=bins_per_octave) + + return float(tuning) + + +def compute_chroma_cens(y, sr=22050, hop_length=512, n_chroma=12, + n_octaves=7, bins_per_octave=36, + win_len_smooth=41, norm=2): + """Compute Chroma Energy Normalized Statistics (CENS) features.""" + + tuning = estimate_tuning(y, sr, bins_per_octave=bins_per_octave) + + fmin = 32.70319566257483 # C1 note frequency + n_bins = n_octaves * bins_per_octave + cqt_mag = compute_cqt(y, sr=sr, hop_length=hop_length, + fmin=fmin, n_bins=n_bins, + bins_per_octave=bins_per_octave, + tuning=tuning) + + chroma_map = cq_to_chroma_mapping(n_bins, bins_per_octave=bins_per_octave, + n_chroma=n_chroma, fmin=fmin) + chroma = np.dot(chroma_map, cqt_mag) + + threshold = np.finfo(chroma.dtype).tiny + chroma_sum = np.sum(np.abs(chroma), axis=0, keepdims=True) + chroma_sum = np.maximum(chroma_sum, threshold) + chroma = chroma / chroma_sum + + quant_steps = [0.4, 0.2, 0.1, 0.05] + quant_weights = [0.25, 0.25, 0.25, 0.25] + chroma_quant = np.zeros_like(chroma) + for step, weight in zip(quant_steps, quant_weights): + chroma_quant += (chroma > step) * weight + + if win_len_smooth is not None and win_len_smooth > 0: + win = scipy.signal.get_window('hann', win_len_smooth + 2, fftbins=False) + win /= np.sum(win) + win = win.reshape(1, -1) + chroma_smooth = scipy.ndimage.convolve(chroma_quant, win, mode='constant') + else: + chroma_smooth = chroma_quant + + if norm == 2: + threshold = np.finfo(chroma_smooth.dtype).tiny + chroma_norm = np.sqrt(np.sum(chroma_smooth ** 2, axis=0, keepdims=True)) + chroma_norm = np.maximum(chroma_norm, threshold) + chroma_smooth = chroma_smooth / chroma_norm + elif norm == np.inf: + threshold = np.finfo(chroma_smooth.dtype).tiny + chroma_norm = np.max(np.abs(chroma_smooth), axis=0, keepdims=True) + chroma_norm = np.maximum(chroma_norm, threshold) + chroma_smooth = chroma_smooth / chroma_norm + + return chroma_smooth + + +def _create_mel_filterbank(sr, n_fft, n_mels=128, fmin=0.0, fmax=None): + """Create mel-scale filterbank matrix.""" + if fmax is None: + fmax = sr / 2.0 + mel_basis = np.zeros((n_mels, int(1 + n_fft // 2)), dtype=np.float32) + fftfreqs = np.fft.rfftfreq(n=n_fft, d=1.0 / sr) + min_mel = hz_to_mel(fmin) + max_mel = hz_to_mel(fmax) + mels = np.linspace(min_mel, max_mel, n_mels + 2) + mel_f = mel_to_hz(mels) + fdiff = np.diff(mel_f) + ramps = np.subtract.outer(mel_f, fftfreqs) + + for i in range(n_mels): + lower = -ramps[i] / fdiff[i] + upper = ramps[i + 2] / fdiff[i + 1] + mel_basis[i] = np.maximum(0, np.minimum(lower, upper)) + + enorm = 2.0 / (mel_f[2:n_mels + 2] - mel_f[:n_mels]) + mel_basis *= enorm[:, np.newaxis] + return mel_basis + + +def _compute_mel_spectrogram(data, sr, n_fft=2048, hop_length=512, n_mels=128): + """Compute mel spectrogram from audio signal.""" + fft_window = scipy.signal.get_window('hann', n_fft, fftbins=True) + if len(fft_window) < n_fft: + lpad = int((n_fft - len(fft_window)) // 2) + fft_window = np.pad(fft_window, (lpad, int(n_fft - len(fft_window) - lpad)), mode='constant') + + fft_window = fft_window.reshape((-1, 1)) + data_padded = np.pad(data, int(n_fft // 2), mode='constant') + n_frames = 1 + (len(data_padded) - n_fft) // hop_length + shape = (n_fft, n_frames) + strides = (data_padded.strides[0], data_padded.strides[0] * hop_length) + frames = np.lib.stride_tricks.as_strided(data_padded, shape=shape, strides=strides) + + stft_result = scipy.fft.rfft(fft_window * frames, axis=0).astype(np.complex64) + power_spec = np.abs(stft_result) ** 2 + + mel_basis = _create_mel_filterbank(sr, n_fft, n_mels=n_mels, fmin=0.0, fmax=sr / 2.0) + mel_spec = np.dot(mel_basis, power_spec) + return mel_spec.astype(np.float32) + + +def quick_tempo_estimate(audio_np, sr, start_bpm=120.0, std_bpm=1.0, hop_length=512): + """Estimate tempo using autocorrelation tempogram.""" + + if len(audio_np) < hop_length * 10: + logging.warning("Audio too short for tempo estimation, returning default BPM of 120.0") + return 120.0 + + n_fft = 2048 + mel_S = _compute_mel_spectrogram(audio_np, sr, n_fft=n_fft, hop_length=hop_length, n_mels=128) + log_mel_S = 10.0 * np.log10(np.maximum(1e-10, mel_S)) + + lag = 1 + S_diff = log_mel_S[:, lag:] - log_mel_S[:, :-lag] + S_onset = np.maximum(0.0, S_diff) + onset_env_pre = np.mean(S_onset, axis=0) + pad_width = lag + n_fft // (2 * hop_length) + onset_env = np.pad(onset_env_pre, (pad_width, 0), mode='constant') + onset_env = onset_env[:mel_S.shape[1]] + + return estimate_tempo_from_onset(onset_env, sr, hop_length, start_bpm, std_bpm, max_tempo=320.0) + + +def estimate_tempo_from_onset(onset_env, sr, hop_length, start_bpm=120.0, std_bpm=1.0, max_tempo=320.0): + """Estimate tempo from onset strength envelope using autocorrelation tempogram.""" + if len(onset_env) < 20: + return 120.0 + + ac_size = 8.0 + win_length = int(np.round(ac_size * sr / hop_length)) + win_length = min(win_length, len(onset_env)) + + pad_width = win_length // 2 + onset_padded = np.pad(onset_env, (pad_width, pad_width), mode='linear_ramp', end_values=(0, 0)) + + n_frames = len(onset_env) + shape = (win_length, n_frames) + strides = (onset_padded.strides[0], onset_padded.strides[0]) + frames = np.lib.stride_tricks.as_strided(onset_padded, shape=shape, strides=strides) + + hann_window = scipy.signal.get_window('hann', win_length, fftbins=True) + windowed_frames = frames * hann_window[:, np.newaxis] + + tempogram = np.zeros((win_length, n_frames)) + for i in range(n_frames): + frame = windowed_frames[:, i] + n_pad = scipy.fft.next_fast_len(2 * len(frame) - 1) + fft_result = scipy.fft.rfft(frame, n=n_pad) + powspec = np.abs(fft_result) ** 2 + ac = scipy.fft.irfft(powspec, n=n_pad) + tempogram[:, i] = ac[:win_length] + + ac_max = np.max(np.abs(tempogram), axis=0) + mask = ac_max > 0 + tempogram[:, mask] /= ac_max[mask] + + tempogram_mean = np.mean(tempogram, axis=1) + tempogram_mean = np.maximum(tempogram_mean, 0) + + bpms = np.zeros(win_length, dtype=np.float64) + bpms[0] = np.inf + bpms[1:] = 60.0 * sr / (hop_length * np.arange(1.0, win_length)) + + logprior = -0.5 * ((np.log2(bpms) - np.log2(start_bpm)) / std_bpm) ** 2 + + if max_tempo is not None: + max_idx = int(np.argmax(bpms < max_tempo)) + if max_idx > 0: + logprior[:max_idx] = -np.inf + + weighted = np.log1p(1e6 * tempogram_mean) + logprior + best_idx = int(np.argmax(weighted[1:])) + 1 + tempo = bpms[best_idx] + + return tempo + + +def detect_onset_peaks(onset_env, sr=22050, hop_length=512, pre_max=0.03, post_max=0.0, + pre_avg=0.10, post_avg=0.10, wait=0.03, delta=0.07): + """Detect onset peaks using peak picking algorithm.""" + + onset_normalized = onset_env - np.min(onset_env) + onset_max = np.max(onset_normalized) + if onset_max > 0: + onset_normalized = onset_normalized / onset_max + + pre_max_frames = int(pre_max * sr / hop_length) + post_max_frames = int(post_max * sr / hop_length) + 1 + pre_avg_frames = int(pre_avg * sr / hop_length) + post_avg_frames = int(post_avg * sr / hop_length) + 1 + wait_frames = int(wait * sr / hop_length) + + peaks = np.zeros(len(onset_normalized), dtype=bool) + peaks[0] = (onset_normalized[0] >= np.max(onset_normalized[:min(post_max_frames, len(onset_normalized))])) + peaks[0] &= (onset_normalized[0] >= np.mean(onset_normalized[:min(post_avg_frames, len(onset_normalized))]) + delta) + + if peaks[0]: + n = wait_frames + 1 + else: + n = 1 + + while n < len(onset_normalized): + maxn = np.max(onset_normalized[max(0, n - pre_max_frames):min(n + post_max_frames, len(onset_normalized))]) + peaks[n] = (onset_normalized[n] == maxn) + + if not peaks[n]: + n += 1 + continue + + avgn = np.mean(onset_normalized[max(0, n - pre_avg_frames):min(n + post_avg_frames, len(onset_normalized))]) + peaks[n] &= (onset_normalized[n] >= avgn + delta) + + if not peaks[n]: + n += 1 + continue + + n += wait_frames + 1 + + return np.flatnonzero(peaks).astype(np.int32) + + +def track_beats(onset_env, tempo, sr, hop_length, tightness=100, trim=True): + """Track beats using dynamic programming.""" + + frame_rate = sr / hop_length + frames_per_beat = np.round(frame_rate * 60.0 / tempo) + + if frames_per_beat <= 0 or len(onset_env) < 2: + return np.array([], dtype=np.int32) + + onset_std = np.std(onset_env, ddof=1) + if onset_std > 0: + onset_normalized = onset_env / onset_std + else: + onset_normalized = onset_env + + window_range = np.arange(-frames_per_beat, frames_per_beat + 1) + window = np.exp(-0.5 * (window_range * 32.0 / frames_per_beat) ** 2) + + localscore = scipy.signal.convolve(onset_normalized, window, mode='same') + + backlink = np.full(len(localscore), -1, dtype=np.int32) + cumscore = np.zeros(len(localscore), dtype=np.float64) + + score_thresh = 0.01 * localscore.max() + first_beat = True + + backlink[0] = -1 + cumscore[0] = localscore[0] + + fpb = int(frames_per_beat) + + for i in range(1, len(localscore)): + score_i = localscore[i] + best_score = -np.inf + beat_location = -1 + + search_start = int(i - np.round(fpb / 2.0)) + search_end = int(i - 2 * fpb - 1) + + for loc in range(search_start, search_end, -1): + if loc < 0: + break + + score = cumscore[loc] - tightness * (np.log(i - loc) - np.log(fpb)) ** 2 + + if score > best_score: + best_score = score + beat_location = loc + + if beat_location >= 0: + cumscore[i] = score_i + best_score + else: + cumscore[i] = score_i + + if first_beat and score_i < score_thresh: + backlink[i] = -1 + else: + backlink[i] = beat_location + first_beat = False + + local_max_mask = np.zeros(len(cumscore), dtype=bool) + + local_max_mask[0] = False + + for i in range(1, len(cumscore) - 1): + local_max_mask[i] = (cumscore[i] > cumscore[i-1]) and (cumscore[i] >= cumscore[i+1]) + + if len(cumscore) > 1: + local_max_mask[-1] = cumscore[-1] > cumscore[-2] + + if np.any(local_max_mask): + median_max = np.median(cumscore[local_max_mask]) + threshold = 0.5 * median_max + + tail = -1 + for i in range(len(cumscore) - 1, -1, -1): + if local_max_mask[i] and cumscore[i] >= threshold: + tail = i + break + else: + tail = len(cumscore) - 1 + + beats = np.zeros(len(localscore), dtype=bool) + n = tail + visited = set() + while n >= 0 and n not in visited: + beats[n] = True + visited.add(n) + n = backlink[n] + + if trim and np.any(beats): + beat_positions = np.flatnonzero(beats) + + beat_localscores = localscore[beat_positions] + + w = np.hanning(5) + smooth_boe_full = np.convolve(beat_localscores, w) + smooth_boe = smooth_boe_full[len(w)//2 : len(localscore) + len(w)//2] + + threshold = 0.5 * np.sqrt(np.mean(smooth_boe ** 2)) + + start_frame = 0 + while start_frame < len(localscore) and localscore[start_frame] <= threshold: + beats[start_frame] = False + start_frame += 1 + + end_frame = len(localscore) - 1 + while end_frame >= 0 and localscore[end_frame] <= threshold: + beats[end_frame] = False + end_frame -= 1 + + return np.flatnonzero(beats).astype(np.int32) + +def compute_onset_envelope(mel_spec_db, n_fft=2048, hop_length=512): + """Compute onset strength envelope from a log-mel spectrogram (dB).""" + lag = 1 + onset_diff = mel_spec_db[:, lag:] - mel_spec_db[:, :-lag] + onset_diff = np.maximum(0.0, onset_diff) + envelope_pre_pad = np.mean(onset_diff, axis=0) + + pad_width = lag + n_fft // (2 * hop_length) + envelope = np.pad(envelope_pre_pad, (pad_width, 0), mode='constant') + envelope = envelope[:mel_spec_db.shape[1]] + + return envelope + +def compute_mfcc(mel_spec_db, n_mfcc=20): + """Compute MFCC features from a log-mel spectrogram (dB).""" + mfcc = scipy.fft.dct(mel_spec_db, axis=0, type=2, norm='ortho')[:n_mfcc].T + return mfcc.astype(np.float32) + + +def power_to_db(S, amin=1e-10, top_db=80.0, ref=1.0): + """Convert a power spectrogram (amplitude squared) to decibel (dB) units""" + S = np.asarray(S) + log_spec = 10.0 * np.log10(np.maximum(amin, S)) + log_spec -= 10.0 * np.log10(np.maximum(amin, ref)) + if top_db is not None: + log_spec = np.maximum(log_spec, log_spec.max() - top_db) + return log_spec + + +class WanDancerEncodeAudio(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="WanDancerEncodeAudio", + category="conditioning/video_models", + inputs=[ + io.Audio.Input("audio"), + io.Int.Input("video_frames", default=149, min=1, max=nodes.MAX_RESOLUTION, step=4), + io.Float.Input("audio_inject_scale", default=1.0, min=0.0, max=10.0, step=0.01, tooltip="The scale for the audio features when injected into the video model."), + ], + outputs=[ + io.AudioEncoderOutput.Output(display_name="audio_encoder_output"), + io.String.Output(display_name="fps_string", tooltip="The calculated fps based on the audio length and the number of video frames. Used in the prompt."), + ], + ) + + @classmethod + def execute(cls, video_frames, audio_inject_scale, audio) -> io.NodeOutput: + waveform = audio["waveform"][0] + sample_rate = audio["sample_rate"] + base_fps = 30 + hop_length = 512 + model_sr = 22050 + n_fft = 2048 + + # start tempo from original audio (not the resampled one) to match the reference pipeline + if waveform.shape[0] > 1: + waveform = waveform.mean(dim=0, keepdim=False) + + start_bpm = quick_tempo_estimate(waveform.squeeze().cpu().numpy(), sample_rate, hop_length=hop_length) + + # resample to the sample rate used for feature extraction + resample_sr = base_fps * hop_length + waveform = torchaudio.functional.resample(waveform, sample_rate, resample_sr) + + waveform_np = waveform.cpu().numpy().squeeze() + mel_spec = _compute_mel_spectrogram(waveform_np, model_sr, n_fft, hop_length, n_mels=128) + mel_spec_db = power_to_db(mel_spec, amin=1e-10, top_db=80.0, ref=1.0) + envelope = compute_onset_envelope(mel_spec_db, n_fft, hop_length) + mfcc = compute_mfcc(mel_spec_db, n_mfcc=20) + chroma = compute_chroma_cens(y=waveform_np, sr=model_sr, hop_length=hop_length).T + # detect peaks + peak_idxs = detect_onset_peaks(envelope, sr=model_sr, hop_length=hop_length) + peak_onehot = np.zeros_like(envelope, dtype=np.float32) + peak_onehot[peak_idxs] = 1.0 + # detect beats + beat_tracking_tempo = estimate_tempo_from_onset(envelope, sr=model_sr, hop_length=hop_length, start_bpm=start_bpm) + beat_idxs = track_beats(envelope, beat_tracking_tempo, model_sr, hop_length, tightness=100, trim=True) + beat_onehot = np.zeros_like(envelope, dtype=np.float32) + beat_onehot[beat_idxs] = 1.0 + + audio_feature = np.concatenate( + [envelope[:, None], mfcc, chroma, peak_onehot[:, None], beat_onehot[:, None]], + axis=-1, + ) + audio_feature = torch.from_numpy(audio_feature).unsqueeze(0).to(comfy.model_management.intermediate_device()) + + fps = float(base_fps / int(audio_feature.shape[1] / video_frames + 0.5)) + + audio_encoder_output = { + "audio_feature": audio_feature, + "fps": fps, + "audio_inject_scale": audio_inject_scale, + } + + if int(fps + 0.5) != 30: + fps_string = " 帧率是{:.4f}".format(fps) # "frame rate is" in Chinese, as it was in the original pipeline + else: + fps_string = ", 帧率是30fps。" # to match the reference pipeline when the fps is 30 + + return io.NodeOutput(audio_encoder_output, fps_string) + + +class WanDancerVideo(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="WanDancerVideo", + category="conditioning/video_models", + inputs=[ + io.Conditioning.Input("positive"), + io.Conditioning.Input("negative"), + io.Vae.Input("vae"), + io.Int.Input("width", default=480, min=16, max=nodes.MAX_RESOLUTION, step=16), + io.Int.Input("height", default=832, min=16, max=nodes.MAX_RESOLUTION, step=16), + io.Int.Input("length", default=149, min=1, max=nodes.MAX_RESOLUTION, step=4, tooltip="The number of frames in the generated video. Should stay 149 for WanDancer."), + io.ClipVisionOutput.Input("clip_vision_output", optional=True, tooltip="The CLIP vision embeds for the first frame."), + io.ClipVisionOutput.Input("clip_vision_output_ref", optional=True, tooltip="The CLIP vision embeds for the reference image."), + io.Image.Input("start_image", optional=True, tooltip="The initial image(s) to be encoded, can be any number of frames."), + io.Mask.Input("mask", optional=True, tooltip="Image conditioning mask for the start image(s). White is kept, black is generated. Used for the local generations."), + io.AudioEncoderOutput.Input("audio_encoder_output", optional=True), + ], + outputs=[ + io.Conditioning.Output(display_name="positive"), + io.Conditioning.Output(display_name="negative"), + io.Latent.Output(display_name="latent", tooltip="Empty latent."), + ], + ) + + @classmethod + def execute(cls, positive, negative, vae, width, height, length, start_image=None, mask=None, clip_vision_output=None, clip_vision_output_ref=None, audio_encoder_output=None) -> io.NodeOutput: + latent = torch.zeros([1, 16, ((length - 1) // 4) + 1, height // 8, width // 8], device=comfy.model_management.intermediate_device()) + if start_image is not None: + start_image = comfy.utils.common_upscale(start_image[:length].movedim(-1, 1), width, height, "bilinear", "center").movedim(1, -1) + image = torch.zeros((length, height, width, start_image.shape[-1]), device=start_image.device, dtype=start_image.dtype) + image[:start_image.shape[0]] = start_image + + concat_latent_image = vae.encode(image[:, :, :, :3]) + if mask is None: + concat_mask = torch.ones((1, 1, latent.shape[2], concat_latent_image.shape[-2], concat_latent_image.shape[-1]), device=start_image.device, dtype=start_image.dtype) + concat_mask[:, :, :((start_image.shape[0] - 1) // 4) + 1] = 0.0 + else: + concat_mask = 1 - mask[:length].unsqueeze(0) + concat_mask = comfy.utils.common_upscale(concat_mask, concat_latent_image.shape[-2], concat_latent_image.shape[-1], "nearest-exact", "disabled") + concat_mask = torch.cat([torch.repeat_interleave(concat_mask[:, 0:1], repeats=4, dim=1), concat_mask[:, 1:]], dim=1) + concat_mask = concat_mask.view(1, concat_mask.shape[1] // 4, 4, concat_latent_image.shape[-2], concat_latent_image.shape[-1]).transpose(1, 2) + + positive = node_helpers.conditioning_set_values(positive, {"concat_latent_image": concat_latent_image, "concat_mask": concat_mask}) + negative = node_helpers.conditioning_set_values(negative, {"concat_latent_image": concat_latent_image, "concat_mask": concat_mask}) + + if clip_vision_output is not None: + positive = node_helpers.conditioning_set_values(positive, {"clip_vision_output": clip_vision_output, "clip_vision_output_ref": clip_vision_output_ref}) + negative = node_helpers.conditioning_set_values(negative, {"clip_vision_output": clip_vision_output, "clip_vision_output_ref": clip_vision_output_ref}) + + if audio_encoder_output is not None: + positive = node_helpers.conditioning_set_values(positive, {"audio_embed": audio_encoder_output["audio_feature"], "fps": audio_encoder_output["fps"], "audio_inject_scale": audio_encoder_output.get("audio_inject_scale", 1.0)}) + negative = node_helpers.conditioning_set_values(negative, {"audio_embed": audio_encoder_output["audio_feature"], "fps": audio_encoder_output["fps"], "audio_inject_scale": audio_encoder_output.get("audio_inject_scale", 1.0)}) + + out_latent = {} + out_latent["samples"] = latent + return io.NodeOutput(positive, negative, out_latent) + + +class VAEDecodeVideoFramewise(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="VAEDecodeVideoFramewise", + category="latent", + description="Decodes video latents one latent at a time.", + search_aliases=["decode", "decode latent", "latent to image", "render latent"], + inputs=[ + io.Latent.Input("samples", tooltip="The latent to be decoded."), + io.Vae.Input("vae", tooltip="The VAE model used for decoding the latent."), + ], + outputs=[ + io.Image.Output(tooltip="The decoded images."), + ], + ) + + @classmethod + def execute(cls, vae, samples) -> io.NodeOutput: + latent = samples["samples"] + if latent.is_nested: + latent = latent.unbind()[0] + + # reshape temporal dimension into batch + B, C, T, H, W = latent.shape + latent_batched = latent.transpose(1, 2).reshape(B * T, C, 1, H, W) + images = vae.decode(latent_batched).squeeze(1) + + return io.NodeOutput(images) + +class WanDancerPadKeyframes(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="WanDancerPadKeyframes", + category="image/video", + inputs=[ + io.Image.Input("images",), + io.Int.Input("segment_length", default=149, min=1, max=10000, tooltip="Length of this segment (usually 149 frames)"), + io.Int.Input("segment_index", default=0, min=0, max=100, tooltip="Which segment this is (0 for first, 1 for second, etc.)"), + io.Audio.Input("audio", tooltip="Audio to calculate total output frames from and extract segment audio."), + ], + outputs=[ + io.Image.Output(display_name="keyframes_sequence", tooltip="Padded keyframe sequence"), + io.Mask.Output(display_name="keyframes_mask", tooltip="Mask indicating valid frames"), + io.Audio.Output(display_name="audio_segment", tooltip="Audio segment for this video segment"), + ], + ) + + @classmethod + def do_execute(cls, images, segment_length, segment_index, audio): + B, H, W, C = images.shape + fps = 30 + + # calculate total frames + audio_duration = audio["waveform"].shape[-1] / audio["sample_rate"] + segment_duration = segment_length / fps + buffer = 0.2 + num_segments = int((audio_duration - buffer) / segment_duration) + 1 if audio_duration > buffer else 0 + total_frames = num_segments * segment_length + + mask = torch.zeros((segment_length, H, W), device=images.device, dtype=images.dtype) + keyframes = torch.zeros((segment_length, H, W, C), dtype=images.dtype, device=images.device) + + # guard: with no audio or no images, nothing to place — leave keyframes/mask zeroed + if total_frames > 0 and B > 0: + frame_interval = float(total_frames) / B + seg_num = int(math.ceil(total_frames / segment_length)) + is_last_segment = (segment_index == seg_num - 1) + + positions = [] + images_before_this_segment = 0 + + # count images consumed by previous segments + for seg_idx in range(segment_index): + end_idx = (total_frames - segment_length * seg_idx - 1) if seg_idx == seg_num - 1 else (segment_length - 1) + cnt = 0 + while cnt * frame_interval < end_idx - frame_interval: + cnt += 1 + images_before_this_segment += cnt + + # positions for current segment + end_index = (total_frames - segment_length * segment_index - 1) if is_last_segment else (segment_length - 1) + cnt = 0 + while cnt * frame_interval < end_index - frame_interval: + pos = int(math.ceil(frame_interval * cnt)) + positions.append((pos, images_before_this_segment + cnt)) + cnt += 1 + positions.append((end_index, images_before_this_segment + cnt)) + + valid_positions = [(pos, idx) for pos, idx in positions if idx < B and pos < segment_length] + + if valid_positions: + seg_positions, img_indices = zip(*valid_positions) + seg_positions = torch.tensor(seg_positions, dtype=torch.long, device=images.device) + img_indices = torch.tensor(img_indices, dtype=torch.long, device=images.device) + mask[seg_positions] = 1 + keyframes[seg_positions] = images[img_indices] + + # extract audio segment + segment_duration = segment_length / fps + start_time = segment_index * segment_duration + end_time = min(start_time + segment_duration, audio_duration) + + sample_rate = audio["sample_rate"] + start_sample = int(start_time * sample_rate) + end_sample = int(end_time * sample_rate) + + audio_segment_waveform = audio["waveform"][:, :, start_sample:end_sample] + audio_segment = { + "waveform": audio_segment_waveform, + "sample_rate": sample_rate + } + + return keyframes, mask, audio_segment + + @classmethod + def execute(cls, images, segment_length, segment_index, audio=None) -> io.NodeOutput: + return io.NodeOutput(*cls.do_execute(images, segment_length, segment_index, audio)) + +class WanDancerPadKeyframesList(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="WanDancerPadKeyframesList", + category="image/video", + inputs=[ + io.Image.Input("images"), + io.Int.Input("segment_length", default=149, min=1, max=10000, tooltip="Length of each segment (usually 149 frames)"), + io.Int.Input("num_segments", default=1, min=1, max=100, tooltip="How many padded segments to emit as lists."), + io.Audio.Input("audio", tooltip="Audio to slice for each emitted segment."), + ], + outputs=[ + io.Image.Output(display_name="keyframes_sequence", tooltip="Padded keyframe sequences", is_output_list=True), + io.Mask.Output(display_name="keyframes_mask", tooltip="Masks indicating valid frames", is_output_list=True), + io.Audio.Output(display_name="audio_segment", tooltip="Audio segment for each video segment", is_output_list=True), + ], + ) + + @classmethod + def execute(cls, images, segment_length, num_segments, audio=None) -> io.NodeOutput: + outputs = [WanDancerPadKeyframes.do_execute(images, segment_length, i, audio) for i in range(num_segments)] + keyframes, masks, audio_segments = zip(*outputs) + return io.NodeOutput(list(keyframes), list(masks), list(audio_segments)) + +class WanDancerExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ + WanDancerVideo, + VAEDecodeVideoFramewise, + WanDancerEncodeAudio, + WanDancerPadKeyframes, + WanDancerPadKeyframesList, + ] + +async def comfy_entrypoint() -> WanDancerExtension: + return WanDancerExtension() diff --git a/nodes.py b/nodes.py index 5755f0bb8..ec66e54d7 100644 --- a/nodes.py +++ b/nodes.py @@ -2434,6 +2434,7 @@ async def init_builtin_extra_nodes(): "nodes_frame_interpolation.py", "nodes_sam3.py", "nodes_void.py", + "nodes_wandancer.py", ] import_failed = [] From 20f5e474da28bd4225ab61b3d5d791e1b32ba069 Mon Sep 17 00:00:00 2001 From: comfyanonymous <121283862+comfyanonymous@users.noreply.github.com> Date: Sat, 9 May 2026 14:17:00 -0700 Subject: [PATCH 56/56] Use LatentCutToBatch instead. (#13815) Removed VAEDecodeVideoFramewise from nodes_wandancer.py. --- comfy_extras/nodes_wandancer.py | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/comfy_extras/nodes_wandancer.py b/comfy_extras/nodes_wandancer.py index faaeb9020..fc005ed4c 100644 --- a/comfy_extras/nodes_wandancer.py +++ b/comfy_extras/nodes_wandancer.py @@ -842,36 +842,6 @@ class WanDancerVideo(io.ComfyNode): return io.NodeOutput(positive, negative, out_latent) -class VAEDecodeVideoFramewise(io.ComfyNode): - @classmethod - def define_schema(cls): - return io.Schema( - node_id="VAEDecodeVideoFramewise", - category="latent", - description="Decodes video latents one latent at a time.", - search_aliases=["decode", "decode latent", "latent to image", "render latent"], - inputs=[ - io.Latent.Input("samples", tooltip="The latent to be decoded."), - io.Vae.Input("vae", tooltip="The VAE model used for decoding the latent."), - ], - outputs=[ - io.Image.Output(tooltip="The decoded images."), - ], - ) - - @classmethod - def execute(cls, vae, samples) -> io.NodeOutput: - latent = samples["samples"] - if latent.is_nested: - latent = latent.unbind()[0] - - # reshape temporal dimension into batch - B, C, T, H, W = latent.shape - latent_batched = latent.transpose(1, 2).reshape(B * T, C, 1, H, W) - images = vae.decode(latent_batched).squeeze(1) - - return io.NodeOutput(images) - class WanDancerPadKeyframes(io.ComfyNode): @classmethod def define_schema(cls): @@ -992,7 +962,6 @@ class WanDancerExtension(ComfyExtension): async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ WanDancerVideo, - VAEDecodeVideoFramewise, WanDancerEncodeAudio, WanDancerPadKeyframes, WanDancerPadKeyframesList,