Merge branch 'master' into feature/deploy-environment-header
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled

This commit is contained in:
Jedrzej Kosinski 2026-05-04 19:50:47 -07:00 committed by GitHub
commit bcfbf8a33a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 297 additions and 50 deletions

View File

@ -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("--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("--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: if comfy.options.args_parsing:
args = parser.parse_args() args = parser.parse_args()

View File

@ -721,13 +721,15 @@ def load_models_gpu(models, memory_required=0, force_patch_weights=False, minimu
else: else:
minimum_memory_required = max(inference_memory, minimum_memory_required + extra_reserved_memory()) 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: for m in models:
models_temp.add(m) models_temp[m] = None
for mm in m.model_patches_models(): 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 = [] models_to_load = []

View File

@ -37,6 +37,7 @@ def prefetch_queue_pop(queue, device, module):
consumed = queue.pop(0) consumed = queue.pop(0)
if consumed is not None: if consumed is not None:
offload_stream, prefetch_state = consumed offload_stream, prefetch_state = consumed
if offload_stream is not None:
offload_stream.wait_stream(comfy.model_management.current_stream(device)) offload_stream.wait_stream(comfy.model_management.current_stream(device))
_, comfy_modules = prefetch_state _, comfy_modules = prefetch_state
if comfy_modules is not None: if comfy_modules is not None:

View File

@ -253,6 +253,9 @@ def resolve_cast_module_with_vbar(s, dtype, device, bias_dtype, compute_dtype, w
if bias is not None: if bias is not None:
bias = post_cast(s, "bias", bias, bias_dtype, prefetch["resident"], update_weight) bias = post_cast(s, "bias", bias, bias_dtype, prefetch["resident"], update_weight)
if prefetch["signature"] is not None:
prefetch["resident"] = True
return weight, bias return weight, bias

View File

@ -89,7 +89,8 @@ def get_additional_models(conds, dtype):
gligen += get_models_from_cond(conds[k], "gligen") gligen += get_models_from_cond(conds[k], "gligen")
add_models += get_models_from_cond(conds[k], "additional_models") 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 inference_memory = 0
control_models = [] control_models = []

View File

@ -5,12 +5,95 @@ This module handles capability negotiation between frontend and backend,
allowing graceful protocol evolution while maintaining backward compatibility. 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 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 # Default server capabilities
SERVER_FEATURE_FLAGS: dict[str, Any] = { _CORE_FEATURE_FLAGS: dict[str, Any] = {
"supports_preview_metadata": True, "supports_preview_metadata": True,
"max_upload_size": args.max_upload_size * 1024 * 1024, # Convert MB to bytes "max_upload_size": args.max_upload_size * 1024 * 1024, # Convert MB to bytes
"extension": {"manager": {"supports_v4": True}}, "extension": {"manager": {"supports_v4": True}},
@ -18,6 +101,11 @@ SERVER_FEATURE_FLAGS: dict[str, Any] = {
"assets": args.enable_assets, "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( def get_connection_feature(
sockets_metadata: dict[str, dict[str, Any]], sockets_metadata: dict[str, dict[str, Any]],

View File

@ -33,7 +33,7 @@ class OpenAIVideoSora2(IO.ComfyNode):
def define_schema(cls): def define_schema(cls):
return IO.Schema( return IO.Schema(
node_id="OpenAIVideoSora2", node_id="OpenAIVideoSora2",
display_name="OpenAI Sora - Video (Deprecated)", display_name="OpenAI Sora - Video (DEPRECATED)",
category="api node/video/Sora", category="api node/video/Sora",
description=( description=(
"OpenAI video and audio generation.\n\n" "OpenAI video and audio generation.\n\n"

View File

@ -199,6 +199,9 @@ class FILMNet(nn.Module):
def get_dtype(self): def get_dtype(self):
return self.extract.extract_sublevels.convs[0][0].conv.weight.dtype 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): def _build_warp_grids(self, H, W, device):
"""Pre-compute warp grids for all pyramid levels.""" """Pre-compute warp grids for all pyramid levels."""
if (H, W) in self._warp_grids: if (H, W) in self._warp_grids:

View File

@ -74,6 +74,9 @@ class IFNet(nn.Module):
def get_dtype(self): def get_dtype(self):
return self.encode.cnn0.weight.dtype 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): def _build_warp_grids(self, H, W, device):
if (H, W) in self._warp_grids: if (H, W) in self._warp_grids:
return return

View File

@ -37,7 +37,7 @@ class FrameInterpolationModelLoader(io.ComfyNode):
model = cls._detect_and_load(sd) model = cls._detect_and_load(sd)
dtype = torch.float16 if model_management.should_use_fp16(model_management.get_torch_device()) else torch.float32 dtype = torch.float16 if model_management.should_use_fp16(model_management.get_torch_device()) else torch.float32
model.eval().to(dtype) model.eval().to(dtype)
patcher = comfy.model_patcher.ModelPatcher( patcher = comfy.model_patcher.CoreModelPatcher(
model, model,
load_device=model_management.get_torch_device(), load_device=model_management.get_torch_device(),
offload_device=model_management.unet_offload_device(), offload_device=model_management.unet_offload_device(),
@ -78,7 +78,7 @@ class FrameInterpolate(io.ComfyNode):
return io.Schema( return io.Schema(
node_id="FrameInterpolate", node_id="FrameInterpolate",
display_name="Frame Interpolate", display_name="Frame Interpolate",
category="image/video", category="video",
search_aliases=["rife", "film", "frame interpolation", "slow motion", "interpolate frames", "vfi"], search_aliases=["rife", "film", "frame interpolation", "slow motion", "interpolate frames", "vfi"],
inputs=[ inputs=[
FrameInterpolationModel.Input("interp_model"), FrameInterpolationModel.Input("interp_model"),
@ -98,16 +98,13 @@ class FrameInterpolate(io.ComfyNode):
if num_frames < 2 or multiplier < 2: if num_frames < 2 or multiplier < 2:
return io.NodeOutput(images) return io.NodeOutput(images)
model_management.load_model_gpu(interp_model)
device = interp_model.load_device device = interp_model.load_device
dtype = interp_model.model_dtype() dtype = interp_model.model_dtype()
inference_model = interp_model.model inference_model = interp_model.model
activation_mem = inference_model.memory_used_forward(images.shape, dtype)
# Free VRAM for inference activations (model weights + ~20x a single frame's worth) model_management.load_models_gpu([interp_model], memory_required=activation_mem)
H, W = images.shape[1], images.shape[2]
activation_mem = H * W * 3 * images.element_size() * 20
model_management.free_memory(activation_mem, device)
align = getattr(inference_model, "pad_align", 1) 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 # Prepare a single padded frame on device for determining output dimensions
def prepare_frame(idx): def prepare_frame(idx):

View File

@ -11,7 +11,7 @@ class ImageCompare(IO.ComfyNode):
def define_schema(cls): def define_schema(cls):
return IO.Schema( return IO.Schema(
node_id="ImageCompare", node_id="ImageCompare",
display_name="Image Compare", display_name="Compare Images",
description="Compares two images side by side with a slider.", description="Compares two images side by side with a slider.",
category="image", category="image",
essentials_category="Image Tools", essentials_category="Image Tools",

View File

@ -24,7 +24,7 @@ class ImageCrop(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="ImageCrop", node_id="ImageCrop",
search_aliases=["trim"], search_aliases=["trim"],
display_name="Image Crop (Deprecated)", display_name="Crop Image (DEPRECATED)",
category="image/transform", category="image/transform",
is_deprecated=True, is_deprecated=True,
essentials_category="Image Tools", essentials_category="Image Tools",
@ -56,7 +56,7 @@ class ImageCropV2(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="ImageCropV2", node_id="ImageCropV2",
search_aliases=["trim"], search_aliases=["trim"],
display_name="Image Crop", display_name="Crop Image",
category="image/transform", category="image/transform",
essentials_category="Image Tools", essentials_category="Image Tools",
has_intermediate_output=True, has_intermediate_output=True,
@ -109,6 +109,7 @@ class RepeatImageBatch(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="RepeatImageBatch", node_id="RepeatImageBatch",
search_aliases=["duplicate image", "clone image"], search_aliases=["duplicate image", "clone image"],
display_name="Repeat Image Batch",
category="image/batch", category="image/batch",
inputs=[ inputs=[
IO.Image.Input("image"), IO.Image.Input("image"),
@ -131,6 +132,7 @@ class ImageFromBatch(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="ImageFromBatch", node_id="ImageFromBatch",
search_aliases=["select image", "pick from batch", "extract image"], search_aliases=["select image", "pick from batch", "extract image"],
display_name="Get Image from Batch",
category="image/batch", category="image/batch",
inputs=[ inputs=[
IO.Image.Input("image"), IO.Image.Input("image"),
@ -157,7 +159,8 @@ class ImageAddNoise(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="ImageAddNoise", node_id="ImageAddNoise",
search_aliases=["film grain"], search_aliases=["film grain"],
category="image", display_name="Add Noise to Image",
category="image/postprocessing",
inputs=[ inputs=[
IO.Image.Input("image"), IO.Image.Input("image"),
IO.Int.Input( IO.Int.Input(
@ -259,7 +262,7 @@ class ImageStitch(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="ImageStitch", node_id="ImageStitch",
search_aliases=["combine images", "join images", "concatenate images", "side by side"], 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" description="Stitches image2 to image1 in the specified direction.\n"
"If image2 is not provided, returns image1 unchanged.\n" "If image2 is not provided, returns image1 unchanged.\n"
"Optional spacing can be added between images.", "Optional spacing can be added between images.",
@ -434,6 +437,7 @@ class ResizeAndPadImage(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="ResizeAndPadImage", node_id="ResizeAndPadImage",
search_aliases=["fit to size"], search_aliases=["fit to size"],
display_name="Resize And Pad Image",
category="image/transform", category="image/transform",
inputs=[ inputs=[
IO.Image.Input("image"), IO.Image.Input("image"),
@ -485,6 +489,7 @@ class SaveSVGNode(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="SaveSVGNode", node_id="SaveSVGNode",
search_aliases=["export vector", "save vector graphics"], search_aliases=["export vector", "save vector graphics"],
display_name="Save SVG",
description="Save SVG files on disk.", description="Save SVG files on disk.",
category="image/save", category="image/save",
inputs=[ inputs=[
@ -591,7 +596,7 @@ class ImageRotate(IO.ComfyNode):
def define_schema(cls): def define_schema(cls):
return IO.Schema( return IO.Schema(
node_id="ImageRotate", node_id="ImageRotate",
display_name="Image Rotate", display_name="Rotate Image",
search_aliases=["turn", "flip orientation"], search_aliases=["turn", "flip orientation"],
category="image/transform", category="image/transform",
essentials_category="Image Tools", essentials_category="Image Tools",
@ -624,6 +629,7 @@ class ImageFlip(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="ImageFlip", node_id="ImageFlip",
search_aliases=["mirror", "reflect"], search_aliases=["mirror", "reflect"],
display_name="Flip Image",
category="image/transform", category="image/transform",
inputs=[ inputs=[
IO.Image.Input("image"), IO.Image.Input("image"),
@ -650,6 +656,7 @@ class ImageScaleToMaxDimension(IO.ComfyNode):
def define_schema(cls): def define_schema(cls):
return IO.Schema( return IO.Schema(
node_id="ImageScaleToMaxDimension", node_id="ImageScaleToMaxDimension",
display_name="Scale Image to Max Dimension",
category="image/upscaling", category="image/upscaling",
inputs=[ inputs=[
IO.Image.Input("image"), IO.Image.Input("image"),

View File

@ -80,7 +80,8 @@ class ImageCompositeMasked(IO.ComfyNode):
def define_schema(cls): def define_schema(cls):
return IO.Schema( return IO.Schema(
node_id="ImageCompositeMasked", node_id="ImageCompositeMasked",
search_aliases=["paste image", "overlay", "layer"], search_aliases=["overlay", "layer", "paste image", "images composition"],
display_name="Image Composite Masked",
category="image", category="image",
inputs=[ inputs=[
IO.Image.Input("destination"), IO.Image.Input("destination"),
@ -201,6 +202,7 @@ class InvertMask(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="InvertMask", node_id="InvertMask",
search_aliases=["reverse mask", "flip mask"], search_aliases=["reverse mask", "flip mask"],
display_name="Invert Mask",
category="mask", category="mask",
inputs=[ inputs=[
IO.Mask.Input("mask"), IO.Mask.Input("mask"),
@ -222,6 +224,7 @@ class CropMask(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="CropMask", node_id="CropMask",
search_aliases=["cut mask", "extract mask region", "mask slice"], search_aliases=["cut mask", "extract mask region", "mask slice"],
display_name="Crop Mask",
category="mask", category="mask",
inputs=[ inputs=[
IO.Mask.Input("mask"), IO.Mask.Input("mask"),
@ -247,7 +250,8 @@ class MaskComposite(IO.ComfyNode):
def define_schema(cls): def define_schema(cls):
return IO.Schema( return IO.Schema(
node_id="MaskComposite", 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", category="mask",
inputs=[ inputs=[
IO.Mask.Input("destination"), IO.Mask.Input("destination"),
@ -298,6 +302,7 @@ class FeatherMask(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="FeatherMask", node_id="FeatherMask",
search_aliases=["soft edge mask", "blur mask edges", "gradient mask edge"], search_aliases=["soft edge mask", "blur mask edges", "gradient mask edge"],
display_name="Feather Mask",
category="mask", category="mask",
inputs=[ inputs=[
IO.Mask.Input("mask"), IO.Mask.Input("mask"),

View File

@ -59,7 +59,8 @@ class ImageRGBToYUV(io.ComfyNode):
return io.Schema( return io.Schema(
node_id="ImageRGBToYUV", node_id="ImageRGBToYUV",
search_aliases=["color space conversion"], search_aliases=["color space conversion"],
category="image/batch", display_name="Image RGB to YUV",
category="image/color",
inputs=[ inputs=[
io.Image.Input("image"), io.Image.Input("image"),
], ],
@ -81,7 +82,8 @@ class ImageYUVToRGB(io.ComfyNode):
return io.Schema( return io.Schema(
node_id="ImageYUVToRGB", node_id="ImageYUVToRGB",
search_aliases=["color space conversion"], search_aliases=["color space conversion"],
category="image/batch", display_name="Image YUV to RGB",
category="image/color",
inputs=[ inputs=[
io.Image.Input("Y"), io.Image.Input("Y"),
io.Image.Input("U"), io.Image.Input("U"),

View File

@ -20,7 +20,8 @@ class Blend(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="ImageBlend", node_id="ImageBlend",
display_name="Image Blend", search_aliases=["mix images"],
display_name="Blend Images",
category="image/postprocessing", category="image/postprocessing",
essentials_category="Image Tools", essentials_category="Image Tools",
inputs=[ inputs=[
@ -224,6 +225,7 @@ class ImageScaleToTotalPixels(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="ImageScaleToTotalPixels", node_id="ImageScaleToTotalPixels",
display_name="Scale Image to Total Pixels",
category="image/upscaling", category="image/upscaling",
inputs=[ inputs=[
io.Image.Input("image"), io.Image.Input("image"),
@ -568,7 +570,7 @@ class BatchImagesNode(io.ComfyNode):
return io.Schema( return io.Schema(
node_id="BatchImagesNode", node_id="BatchImagesNode",
display_name="Batch Images", display_name="Batch Images",
category="image", category="image/batch",
essentials_category="Image Tools", essentials_category="Image Tools",
search_aliases=["batch", "image batch", "batch images", "combine images", "merge images", "stack images"], search_aliases=["batch", "image batch", "batch images", "combine images", "merge images", "stack images"],
inputs=[ inputs=[

View File

@ -17,7 +17,8 @@ class SaveWEBM(io.ComfyNode):
return io.Schema( return io.Schema(
node_id="SaveWEBM", node_id="SaveWEBM",
search_aliases=["export webm"], search_aliases=["export webm"],
category="image/video", display_name="Save WEBM",
category="video",
is_experimental=True, is_experimental=True,
inputs=[ inputs=[
io.Image.Input("images"), io.Image.Input("images"),
@ -72,7 +73,7 @@ class SaveVideo(io.ComfyNode):
node_id="SaveVideo", node_id="SaveVideo",
search_aliases=["export video"], search_aliases=["export video"],
display_name="Save Video", display_name="Save Video",
category="image/video", category="video",
essentials_category="Basics", essentials_category="Basics",
description="Saves the input images to your ComfyUI output directory.", description="Saves the input images to your ComfyUI output directory.",
inputs=[ inputs=[
@ -121,7 +122,7 @@ class CreateVideo(io.ComfyNode):
node_id="CreateVideo", node_id="CreateVideo",
search_aliases=["images to video"], search_aliases=["images to video"],
display_name="Create Video", display_name="Create Video",
category="image/video", category="video",
description="Create a video from images.", description="Create a video from images.",
inputs=[ inputs=[
io.Image.Input("images", tooltip="The images to create a video from."), io.Image.Input("images", tooltip="The images to create a video from."),
@ -146,7 +147,7 @@ class GetVideoComponents(io.ComfyNode):
node_id="GetVideoComponents", node_id="GetVideoComponents",
search_aliases=["extract frames", "split video", "video to images", "demux"], search_aliases=["extract frames", "split video", "video to images", "demux"],
display_name="Get Video Components", display_name="Get Video Components",
category="image/video", category="video",
description="Extracts all components from a video: frames, audio, and framerate.", description="Extracts all components from a video: frames, audio, and framerate.",
inputs=[ inputs=[
io.Video.Input("video", tooltip="The video to extract components from."), io.Video.Input("video", tooltip="The video to extract components from."),
@ -174,7 +175,7 @@ class LoadVideo(io.ComfyNode):
node_id="LoadVideo", node_id="LoadVideo",
search_aliases=["import video", "open video", "video file"], search_aliases=["import video", "open video", "video file"],
display_name="Load Video", display_name="Load Video",
category="image/video", category="video",
essentials_category="Basics", essentials_category="Basics",
inputs=[ inputs=[
io.Combo.Input("file", options=sorted(files), upload=io.UploadType.video), io.Combo.Input("file", options=sorted(files), upload=io.UploadType.video),
@ -216,7 +217,7 @@ class VideoSlice(io.ComfyNode):
"frame load cap", "frame load cap",
"start time", "start time",
], ],
category="image/video", category="video",
essentials_category="Video Tools", essentials_category="Video Tools",
inputs=[ inputs=[
io.Video.Input("video"), io.Video.Input("video"),

10
main.py
View File

@ -1,13 +1,21 @@
import comfy.options import comfy.options
comfy.options.enable_args_parsing() 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 os
import importlib.util import importlib.util
import shutil import shutil
import importlib.metadata import importlib.metadata
import folder_paths import folder_paths
import time 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 from app.logger import setup_logger
setup_logger(log_level=args.verbose, use_stdout=args.log_stdout) setup_logger(log_level=args.verbose, use_stdout=args.log_stdout)

View File

@ -1887,7 +1887,7 @@ class ImageInvert:
RETURN_TYPES = ("IMAGE",) RETURN_TYPES = ("IMAGE",)
FUNCTION = "invert" FUNCTION = "invert"
CATEGORY = "image" CATEGORY = "image/color"
def invert(self, image): def invert(self, image):
s = 1.0 - image s = 1.0 - image
@ -1903,7 +1903,7 @@ class ImageBatch:
RETURN_TYPES = ("IMAGE",) RETURN_TYPES = ("IMAGE",)
FUNCTION = "batch" FUNCTION = "batch"
CATEGORY = "image" CATEGORY = "image/batch"
DEPRECATED = True DEPRECATED = True
def batch(self, image1, image2): def batch(self, image1, image2):
@ -1960,7 +1960,7 @@ class ImagePadForOutpaint:
RETURN_TYPES = ("IMAGE", "MASK") RETURN_TYPES = ("IMAGE", "MASK")
FUNCTION = "expand_image" FUNCTION = "expand_image"
CATEGORY = "image" CATEGORY = "image/transform"
def expand_image(self, image, left, top, right, bottom, feathering): def expand_image(self, image, left, top, right, bottom, feathering):
d1, d2, d3, d4 = image.size() d1, d2, d3, d4 = image.size()
@ -2103,7 +2103,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"ConditioningSetArea": "Conditioning (Set Area)", "ConditioningSetArea": "Conditioning (Set Area)",
"ConditioningSetAreaPercentage": "Conditioning (Set Area with Percentage)", "ConditioningSetAreaPercentage": "Conditioning (Set Area with Percentage)",
"ConditioningSetMask": "Conditioning (Set Mask)", "ConditioningSetMask": "Conditioning (Set Mask)",
"ControlNetApply": "Apply ControlNet (OLD)", "ControlNetApply": "Apply ControlNet (DEPRECATED)",
"ControlNetApplyAdvanced": "Apply ControlNet", "ControlNetApplyAdvanced": "Apply ControlNet",
# Latent # Latent
"VAEEncodeForInpaint": "VAE Encode (for Inpainting)", "VAEEncodeForInpaint": "VAE Encode (for Inpainting)",
@ -2121,6 +2121,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"LatentFromBatch" : "Latent From Batch", "LatentFromBatch" : "Latent From Batch",
"RepeatLatentBatch": "Repeat Latent Batch", "RepeatLatentBatch": "Repeat Latent Batch",
# Image # Image
"EmptyImage": "Empty Image",
"SaveImage": "Save Image", "SaveImage": "Save Image",
"PreviewImage": "Preview Image", "PreviewImage": "Preview Image",
"LoadImage": "Load Image", "LoadImage": "Load Image",
@ -2128,15 +2129,15 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"LoadImageOutput": "Load Image (from Outputs)", "LoadImageOutput": "Load Image (from Outputs)",
"ImageScale": "Upscale Image", "ImageScale": "Upscale Image",
"ImageScaleBy": "Upscale Image By", "ImageScaleBy": "Upscale Image By",
"ImageInvert": "Invert Image", "ImageInvert": "Invert Image Colors",
"ImagePadForOutpaint": "Pad Image for Outpainting", "ImagePadForOutpaint": "Pad Image for Outpainting",
"ImageBatch": "Batch Images", "ImageBatch": "Batch Images (DEPRECATED)",
"ImageCrop": "Image Crop", "ImageCrop": "Crop Image",
"ImageStitch": "Image Stitch", "ImageStitch": "Stitch Images",
"ImageBlend": "Image Blend", "ImageBlend": "Blend Images",
"ImageBlur": "Image Blur", "ImageBlur": "Blur Image",
"ImageQuantize": "Image Quantize", "ImageQuantize": "Quantize Image",
"ImageSharpen": "Image Sharpen", "ImageSharpen": "Sharpen Image",
"ImageScaleToTotalPixels": "Scale Image to Total Pixels", "ImageScaleToTotalPixels": "Scale Image to Total Pixels",
"GetImageSize": "Get Image Size", "GetImageSize": "Get Image Size",
# _for_testing # _for_testing

View File

@ -1999,6 +1999,26 @@ components:
items: items:
type: string type: string
description: List of node IDs to execute (partial graph execution) 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: PromptResponse:
type: object type: object
@ -2347,7 +2367,12 @@ components:
description: Device type (cuda, mps, cpu, etc.) description: Device type (cuda, mps, cpu, etc.)
index: index:
type: number 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: vram_total:
type: number type: number
description: Total VRAM in bytes description: Total VRAM in bytes
@ -2503,7 +2528,18 @@ components:
description: Alternative search terms for finding this node description: Alternative search terms for finding this node
essentials_category: essentials_category:
type: string 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 # Models

View File

@ -1,10 +1,15 @@
"""Tests for feature flags functionality.""" """Tests for feature flags functionality."""
import pytest
from comfy_api.feature_flags import ( from comfy_api.feature_flags import (
get_connection_feature, get_connection_feature,
supports_feature, supports_feature,
get_server_features, get_server_features,
CLI_FEATURE_FLAG_REGISTRY,
SERVER_FEATURE_FLAGS, 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") result = get_connection_feature(sockets_metadata, "sid1", "any_feature")
assert result is False assert result is False
assert supports_feature(sockets_metadata, "sid1", "any_feature") 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'"