Compare commits

..

5 Commits

Author SHA1 Message Date
70923122a0 PrimitiveInt: default control_after_generate to fixed
Amp-Thread-ID: https://ampcode.com/threads/T-019def7f-a638-72fc-a69c-017c8db2c323
Co-authored-by: Amp <amp@ampcode.com>
2026-05-03 14:17:31 -07:00
cea8d0925f Refactor LoadImageMask to use LoadImage code. (#13687) 2026-05-03 16:18:27 -04:00
b138133ffa Enable triton comfy kitchen via cli-arg (#12730) 2026-05-03 14:07:21 -04:00
025e6792ee Batch broadcasting in JoinImageWithAlpha node (#13686)
* Batch broadcasting in JoinImageWithAlpha node
2026-05-03 16:30:00 +03:00
867b8d2408 fix: gracefully handle port-in-use error on server startup (#13001)
Catch EADDRINUSE OSError when binding the TCP site and exit with a clear error message instead of an unhandled traceback.
2026-05-03 20:44:20 +08:00
13 changed files with 93 additions and 596 deletions

View File

@ -91,6 +91,7 @@ parser.add_argument("--directml", type=int, nargs="?", metavar="DIRECTML_DEVICE"
parser.add_argument("--oneapi-device-selector", type=str, default=None, metavar="SELECTOR_STRING", help="Sets the oneAPI device(s) this instance will use.")
parser.add_argument("--supports-fp8-compute", action="store_true", help="ComfyUI will act like if the device supports fp8 compute.")
parser.add_argument("--enable-triton-backend", action="store_true", help="ComfyUI will enable the use of Triton backend in comfy-kitchen. Is disabled at launch by default.")
class LatentPreviewMethod(enum.Enum):
NoPreviews = "none"

View File

@ -1,6 +1,8 @@
import torch
import logging
from comfy.cli_args import args
try:
import comfy_kitchen as ck
from comfy_kitchen.tensor import (
@ -21,7 +23,15 @@ try:
ck.registry.disable("cuda")
logging.warning("WARNING: You need pytorch with cu130 or higher to use optimized CUDA operations.")
ck.registry.disable("triton")
if args.enable_triton_backend:
try:
import triton
logging.info("Found triton %s. Enabling comfy-kitchen triton backend.", triton.__version__)
except ImportError as e:
logging.error(f"Failed to import triton, Error: {e}, the comfy-kitchen triton backend will not be available.")
ck.registry.disable("triton")
else:
ck.registry.disable("triton")
for k, v in ck.list_backends().items():
logging.info(f"Found comfy_kitchen backend {k}: {v}")
except ImportError as e:

View File

@ -43,67 +43,7 @@ class UploadType(str, Enum):
model = "file_upload"
class RemoteItemSchema:
"""Describes how to map API response objects to rich dropdown items.
All *_field parameters use dot-path notation (e.g. ``"labels.gender"``).
``label_field`` and ``description_field`` additionally support template strings
with ``{field}`` placeholders (e.g. ``"{name} ({labels.accent})"``).
"""
def __init__(
self,
value_field: str,
label_field: str,
preview_url_field: str | None = None,
preview_type: Literal["image", "video", "audio"] = "image",
description_field: str | None = None,
search_fields: list[str] | None = None,
):
if preview_type not in ("image", "video", "audio"):
raise ValueError(
f"RemoteItemSchema: 'preview_type' must be 'image', 'video', or 'audio'; got {preview_type!r}."
)
if search_fields is not None:
for f in search_fields:
if "{" in f or "}" in f:
raise ValueError(
f"RemoteItemSchema: 'search_fields' must be dot-paths, not template strings (got {f!r})."
)
self.value_field = value_field
"""Dot-path to the unique identifier within each item.
This value is stored in the widget and passed to execute()."""
self.label_field = label_field
"""Dot-path to the display name, or a template string with {field} placeholders."""
self.preview_url_field = preview_url_field
"""Dot-path to a preview media URL. If None, no preview is shown."""
self.preview_type = preview_type
"""How to render the preview: "image", "video", or "audio"."""
self.description_field = description_field
"""Optional dot-path or template for a subtitle line shown below the label."""
self.search_fields = search_fields
"""Dot-paths to fields included in the search index. When unset, search falls back to
the resolved label (i.e. ``label_field`` after template substitution). Note that template
label strings (e.g. ``"{first} {last}"``) are not valid path entries here — list the
underlying paths (``["first", "last"]``) instead."""
def as_dict(self):
return prune_dict({
"value_field": self.value_field,
"label_field": self.label_field,
"preview_url_field": self.preview_url_field,
"preview_type": self.preview_type,
"description_field": self.description_field,
"search_fields": self.search_fields,
})
class RemoteOptions:
"""Plain remote combo: fetches a list of strings/objects and populates a standard dropdown.
Use this for lightweight lists from endpoints that return a bare array (or an array under
``response_key``). For rich dropdowns with previews, search, filtering, or pagination,
use :class:`RemoteComboOptions` and the ``remote_combo=`` parameter on ``Combo.Input``.
"""
def __init__(self, route: str, refresh_button: bool, control_after_refresh: Literal["first", "last"]="first",
timeout: int=None, max_retries: int=None, refresh: int=None):
self.route = route
@ -130,80 +70,6 @@ class RemoteOptions:
})
class RemoteComboOptions:
"""Rich remote combo: populates a Vue dropdown with previews, search, and filtering.
Attached to a :class:`Combo.Input` via ``remote_combo=`` (not ``remote=``). Requires an
``item_schema`` describing how to map API response objects to dropdown items.
Response-shape contract: the endpoint returns the full items array in a single response
(either at the top level, or at the dot-path given by ``response_key``). Backing endpoints
that paginate upstream are expected to aggregate and cache server-side.
"""
def __init__(
self,
route: str,
item_schema: RemoteItemSchema,
refresh_button: bool = True,
auto_select: Literal["first", "last"] | None = None,
timeout: int | None = None,
max_retries: int | None = None,
refresh: int | None = None,
response_key: str | None = None,
):
if auto_select is not None and auto_select not in ("first", "last"):
raise ValueError(
f"RemoteComboOptions: 'auto_select' must be 'first', 'last', or None; got {auto_select!r}."
)
if refresh is not None and 0 < refresh < 128:
raise ValueError(
f"RemoteComboOptions: 'refresh' must be >= 128 (ms TTL) or <= 0 (cache never expires); got {refresh}."
)
if timeout is not None and timeout < 0:
raise ValueError(
f"RemoteComboOptions: 'timeout' must be >= 0 (got {timeout})."
)
if max_retries is not None and max_retries < 0:
raise ValueError(
f"RemoteComboOptions: 'max_retries' must be >= 0 (got {max_retries})."
)
if not route.startswith("/"):
raise ValueError(
f"RemoteComboOptions: 'route' must be a relative path starting with '/'; got {route!r}."
)
self.route = route
"""Relative path to the remote source (must start with ``/``). The frontend resolves this
against the comfy-api base URL and injects auth headers; absolute URLs are rejected."""
self.item_schema = item_schema
"""Required: describes how each API response object maps to a dropdown item."""
self.refresh_button = refresh_button
"""Specifies whether to show a refresh button next to the widget."""
self.auto_select = auto_select
"""Fallback item to select when the widget's value is empty. Never overrides an existing
selection. Default None means no fallback."""
self.timeout = timeout
"""Maximum time to wait for a response, in milliseconds."""
self.max_retries = max_retries
"""Maximum number of retries before aborting the request. Default None uses the frontend's built-in limit."""
self.refresh = refresh
"""TTL of the cached value in milliseconds. Must be >= 128 (ms TTL) or <= 0 (cache never expires,
re-fetched only via the refresh button). Default None uses the frontend's built-in behavior."""
self.response_key = response_key
"""Dot-path to the items array within the response (when not at the top level)."""
def as_dict(self):
return prune_dict({
"route": self.route,
"item_schema": self.item_schema.as_dict(),
"refresh_button": self.refresh_button,
"auto_select": self.auto_select,
"timeout": self.timeout,
"max_retries": self.max_retries,
"refresh": self.refresh,
"response_key": self.response_key,
})
class NumberDisplay(str, Enum):
number = "number"
slider = "slider"
@ -493,16 +359,11 @@ class Combo(ComfyTypeIO):
upload: UploadType=None,
image_folder: FolderType=None,
remote: RemoteOptions=None,
remote_combo: RemoteComboOptions=None,
socketless: bool=None,
extra_dict=None,
raw_link: bool=None,
advanced: bool=None,
):
if remote is not None and remote_combo is not None:
raise ValueError("Combo.Input: pass either 'remote' or 'remote_combo', not both.")
if options is not None and remote_combo is not None:
raise ValueError("Combo.Input: pass either 'options' or 'remote_combo', not both.")
if isinstance(options, type) and issubclass(options, Enum):
options = [v.value for v in options]
if isinstance(default, Enum):
@ -514,7 +375,6 @@ class Combo(ComfyTypeIO):
self.upload = upload
self.image_folder = image_folder
self.remote = remote
self.remote_combo = remote_combo
self.default: str
def as_dict(self):
@ -525,7 +385,6 @@ class Combo(ComfyTypeIO):
**({self.upload.value: True} if self.upload is not None else {}),
"image_folder": self.image_folder.value if self.image_folder else None,
"remote": self.remote.as_dict() if self.remote else None,
"remote_combo": self.remote_combo.as_dict() if self.remote_combo else None,
})
class Output(Output):
@ -2362,9 +2221,7 @@ class NodeReplace:
__all__ = [
"FolderType",
"UploadType",
"RemoteItemSchema",
"RemoteOptions",
"RemoteComboOptions",
"NumberDisplay",
"ControlAfterGenerate",

View File

@ -132,10 +132,6 @@ class GetAssetResponse(BaseModel):
error: TaskStatusError | None = Field(None)
class SeedanceCreateVisualValidateSessionRequest(BaseModel):
name: str | None = Field(None, max_length=64)
class SeedanceCreateVisualValidateSessionResponse(BaseModel):
session_id: str = Field(...)
h5_link: str = Field(...)
@ -145,7 +141,6 @@ class SeedanceGetVisualValidateSessionResponse(BaseModel):
session_id: str = Field(...)
status: str = Field(...)
group_id: str | None = Field(None)
name: str | None = Field(None)
error_code: str | None = Field(None)
error_message: str | None = Field(None)

View File

@ -19,7 +19,6 @@ from comfy_api_nodes.apis.bytedance import (
Seedance2TaskCreationRequest,
SeedanceCreateAssetRequest,
SeedanceCreateAssetResponse,
SeedanceCreateVisualValidateSessionRequest,
SeedanceCreateVisualValidateSessionResponse,
SeedanceGetVisualValidateSessionResponse,
SeedanceVirtualLibraryCreateAssetRequest,
@ -197,16 +196,11 @@ def _rewrite_asset_refs(prompt: str, labels: dict[int, str]) -> str:
return _ASSET_REF_RE.sub(_sub, prompt)
async def _obtain_group_id_via_h5_auth(
cls: type[IO.ComfyNode],
group_name: str | None = None,
) -> str:
payload = SeedanceCreateVisualValidateSessionRequest(name=group_name)
async def _obtain_group_id_via_h5_auth(cls: type[IO.ComfyNode]) -> str:
session = await sync_op(
cls,
ApiEndpoint(path="/proxy/seedance/visual-validate/sessions", method="POST"),
response_model=SeedanceCreateVisualValidateSessionResponse,
data=payload,
)
logger.warning("Seedance authentication required. Open link: %s", session.h5_link)
@ -235,15 +229,10 @@ async def _obtain_group_id_via_h5_auth(
return result.group_id
async def _resolve_group_id(
cls: type[IO.ComfyNode],
group_id: str,
group_name: str | None = None,
) -> str:
async def _resolve_group_id(cls: type[IO.ComfyNode], group_id: str) -> str:
if group_id and group_id.strip():
return group_id.strip()
label = (group_name or "").strip() or None
return await _obtain_group_id_via_h5_auth(cls, group_name=label)
return await _obtain_group_id_via_h5_auth(cls)
async def _create_seedance_asset(
@ -1947,55 +1936,6 @@ async def process_video_task(
return IO.NodeOutput(await download_url_to_video_output(response.content.video_url))
def _seedance_group_picker_input() -> IO.Combo.Input:
"""Combo populated from /proxy/seedance/visual-validate/groups. Empty selection triggers H5 enrollment."""
return IO.Combo.Input(
"group_id",
default="",
tooltip=(
"Pick an existing verified group, or leave empty to run real-person H5 "
"authentication and create a new group."
),
remote_combo=IO.RemoteComboOptions(
route="/proxy/seedance/visual-validate/groups",
response_key="groups",
item_schema=IO.RemoteItemSchema(
value_field="group_id",
label_field="name",
description_field="created_at",
search_fields=["name", "group_id"],
),
refresh=60_000,
),
optional=True,
)
def _seedance_group_name_input() -> IO.String.Input:
return IO.String.Input(
"group_name",
default="",
tooltip=(
"Optional label for a new group. Used only when group_id is empty; the label is "
"shown later in the group picker so you can identify this group at a glance. "
"Up to 64 characters."
),
optional=True,
)
def _seedance_asset_name_input() -> IO.String.Input:
return IO.String.Input(
"asset_name",
default="",
tooltip=(
"Optional label for the asset, shown in the asset selector dropdown. "
"Up to 64 characters. Leave empty to identify the asset by its id."
),
optional=True,
)
class ByteDanceCreateImageAsset(IO.ComfyNode):
@classmethod
@ -2006,15 +1946,22 @@ class ByteDanceCreateImageAsset(IO.ComfyNode):
category="api node/image/ByteDance",
description=(
"Create a Seedance 2.0 personal image asset. Uploads the input image and "
"registers it in the selected asset group. Leave group_id empty to run a "
"real-person H5 authentication flow and create a new group; provide group_name "
"to label the new group."
"registers it in the given asset group. If group_id is empty, runs a real-person "
"H5 authentication flow to create a new group before adding the asset."
),
inputs=[
IO.Image.Input("image", tooltip="Image to register as a personal asset."),
_seedance_group_picker_input(),
_seedance_group_name_input(),
_seedance_asset_name_input(),
IO.String.Input(
"group_id",
default="",
tooltip="Reuse an existing Seedance asset group ID to skip repeated human verification for the "
"same person. Leave empty to run real-person authentication in the browser and create a new group.",
),
# IO.String.Input(
# "name",
# default="",
# tooltip="Asset name (up to 64 characters).",
# ),
],
outputs=[
IO.String.Output(display_name="asset_id"),
@ -2033,17 +1980,18 @@ class ByteDanceCreateImageAsset(IO.ComfyNode):
cls,
image: Input.Image,
group_id: str = "",
group_name: str = "",
asset_name: str = "",
# name: str = "",
) -> IO.NodeOutput:
# if len(name) > 64:
# raise ValueError("Name of asset can not be greater then 64 symbols")
validate_image_dimensions(image, min_width=300, max_width=6000, min_height=300, max_height=6000)
validate_image_aspect_ratio(image, min_ratio=(0.4, 1), max_ratio=(2.5, 1))
resolved_group = await _resolve_group_id(cls, group_id, group_name=group_name)
resolved_group = await _resolve_group_id(cls, group_id)
asset_id = await _create_seedance_asset(
cls,
group_id=resolved_group,
url=await upload_image_to_comfyapi(cls, image),
name=asset_name.strip()[:64],
name="",
asset_type="Image",
)
await _wait_for_asset_active(cls, asset_id, resolved_group)
@ -2065,15 +2013,22 @@ class ByteDanceCreateVideoAsset(IO.ComfyNode):
category="api node/video/ByteDance",
description=(
"Create a Seedance 2.0 personal video asset. Uploads the input video and "
"registers it in the selected asset group. Leave group_id empty to run a "
"real-person H5 authentication flow and create a new group; provide group_name "
"to label the new group."
"registers it in the given asset group. If group_id is empty, runs a real-person "
"H5 authentication flow to create a new group before adding the asset."
),
inputs=[
IO.Video.Input("video", tooltip="Video to register as a personal asset."),
_seedance_group_picker_input(),
_seedance_group_name_input(),
_seedance_asset_name_input(),
IO.String.Input(
"group_id",
default="",
tooltip="Reuse an existing Seedance asset group ID to skip repeated human verification for the "
"same person. Leave empty to run real-person authentication in the browser and create a new group.",
),
# IO.String.Input(
# "name",
# default="",
# tooltip="Asset name (up to 64 characters).",
# ),
],
outputs=[
IO.String.Output(display_name="asset_id"),
@ -2092,9 +2047,10 @@ class ByteDanceCreateVideoAsset(IO.ComfyNode):
cls,
video: Input.Video,
group_id: str = "",
group_name: str = "",
asset_name: str = "",
# name: str = "",
) -> IO.NodeOutput:
# if len(name) > 64:
# raise ValueError("Name of asset can not be greater then 64 symbols")
validate_video_duration(video, min_duration=2, max_duration=15)
validate_video_dimensions(video, min_width=300, max_width=6000, min_height=300, max_height=6000)
@ -2113,12 +2069,12 @@ class ByteDanceCreateVideoAsset(IO.ComfyNode):
if not (24 <= fps <= 60):
raise ValueError(f"Asset video FPS must be in [24, 60], got {fps:.2f}.")
resolved_group = await _resolve_group_id(cls, group_id, group_name=group_name)
resolved_group = await _resolve_group_id(cls, group_id)
asset_id = await _create_seedance_asset(
cls,
group_id=resolved_group,
url=await upload_video_to_comfyapi(cls, video),
name=asset_name.strip()[:64],
name="",
asset_type="Video",
)
await _wait_for_asset_active(cls, asset_id, resolved_group)
@ -2130,92 +2086,6 @@ class ByteDanceCreateVideoAsset(IO.ComfyNode):
return IO.NodeOutput(asset_id, resolved_group)
def _seedance_asset_picker_input(asset_type: str, preview_type: str) -> IO.Combo.Input:
"""Combo populated from /proxy/seedance/assets, scoped to one asset_type."""
return IO.Combo.Input(
"asset_id",
tooltip=(
f"Pick a previously-created Seedance {asset_type.lower()} asset. The dropdown shows "
"your assets across all your verified groups; type a group name to filter."
),
remote_combo=IO.RemoteComboOptions(
route=f"/proxy/seedance/assets?asset_type={asset_type}",
response_key="assets",
item_schema=IO.RemoteItemSchema(
value_field="asset_id",
label_field="name",
description_field="group_name",
preview_url_field="url",
preview_type=preview_type,
search_fields=["name", "asset_id", "group_name", "group_id"],
),
refresh=60_000,
),
)
class ByteDanceSelectImageAsset(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="ByteDanceSelectImageAsset",
display_name="ByteDance Select Image Asset",
category="api node/image/ByteDance",
description=(
"Pick a previously-created Seedance image asset. Outputs the selected asset_id "
"for use with downstream Seedance 2.0 reference/first-last-frame nodes."
),
inputs=[
_seedance_asset_picker_input("Image", "image"),
],
outputs=[IO.String.Output(display_name="asset_id")],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
# is_api_node=True,
)
@classmethod
async def execute(cls, asset_id: str) -> IO.NodeOutput:
if not asset_id or not asset_id.strip():
raise ValueError("asset_id is required. Pick an asset from the dropdown.")
return IO.NodeOutput(asset_id.strip())
class ByteDanceSelectVideoAsset(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="ByteDanceSelectVideoAsset",
display_name="ByteDance Select Video Asset",
category="api node/video/ByteDance",
description=(
"Pick a previously-created Seedance video asset. Outputs the selected asset_id "
"for use with downstream Seedance 2.0 reference/first-last-frame nodes."
),
inputs=[
_seedance_asset_picker_input("Video", "video"),
],
outputs=[IO.String.Output(display_name="asset_id")],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
# is_api_node=True,
)
@classmethod
async def execute(cls, asset_id: str) -> IO.NodeOutput:
if not asset_id or not asset_id.strip():
raise ValueError("asset_id is required. Pick an asset from the dropdown.")
return IO.NodeOutput(asset_id.strip())
class ByteDanceExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
@ -2231,8 +2101,6 @@ class ByteDanceExtension(ComfyExtension):
ByteDance2ReferenceNode,
ByteDanceCreateImageAsset,
ByteDanceCreateVideoAsset,
ByteDanceSelectImageAsset,
ByteDanceSelectVideoAsset,
]

View File

@ -233,44 +233,6 @@ class ElevenLabsVoiceSelector(IO.ComfyNode):
return IO.NodeOutput(voice_id)
class ElevenLabsRichVoiceSelector(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="ElevenLabsRichVoiceSelector",
display_name="ElevenLabs Voice Selector (Rich)",
category="api node/audio/ElevenLabs",
description="Select an ElevenLabs voice with audio preview and rich metadata.",
inputs=[
IO.Combo.Input(
"voice",
remote_combo=IO.RemoteComboOptions(
route="/proxy/elevenlabs/v2/voices?page_size=100",
response_key="items",
refresh_button=True,
refresh=43200000,
item_schema=IO.RemoteItemSchema(
value_field="voice_id",
label_field="name",
preview_url_field="preview_url",
preview_type="audio",
search_fields=["name", "labels.gender", "labels.accent", "labels.use_case"],
),
),
tooltip="Choose a voice with audio preview.",
),
],
outputs=[
IO.Custom(ELEVENLABS_VOICE).Output(display_name="voice"),
],
is_api_node=False,
)
@classmethod
def execute(cls, voice: str) -> IO.NodeOutput:
return IO.NodeOutput(voice) # voice is already the voice_id from item_schema.value_field
class ElevenLabsTextToSpeech(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
@ -949,7 +911,6 @@ class ElevenLabsExtension(ComfyExtension):
return [
ElevenLabsSpeechToText,
ElevenLabsVoiceSelector,
ElevenLabsRichVoiceSelector,
ElevenLabsTextToSpeech,
ElevenLabsAudioIsolation,
ElevenLabsTextToSoundEffects,

View File

@ -3288,53 +3288,6 @@ class KlingAvatarNode(IO.ComfyNode):
return IO.NodeOutput(await download_url_to_video_output(final_response.data.task_result.videos[0].url))
KLING_ELEMENT_ID = "KLING_ELEMENT_ID"
class KlingElementSelector(IO.ComfyNode):
"""Select a Kling preset element (character, scene, effect, etc.) for use in video generation."""
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="KlingElementSelector",
display_name="Kling Element Selector",
category="api node/video/Kling",
description="Browse and select a Kling preset element with image preview. Elements provide consistent characters, scenes, costumes, and effects for video generation.",
inputs=[
IO.Combo.Input(
"element",
remote_combo=IO.RemoteComboOptions(
route="/proxy/kling/v1/general/advanced-presets-elements",
refresh_button=True,
refresh=43200000,
response_key="data",
item_schema=IO.RemoteItemSchema(
value_field="task_result.elements.0.element_id",
label_field="task_result.elements.0.element_name",
preview_url_field="task_result.elements.0.element_image_list.frontal_image",
preview_type="image",
description_field="task_result.elements.0.element_description",
search_fields=["task_result.elements.0.element_name", "task_result.elements.0.element_description"],
),
),
tooltip="Select a preset element to use in video generation.",
),
],
outputs=[IO.Custom(KLING_ELEMENT_ID).Output(display_name="element_id")],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=False,
)
@classmethod
async def execute(cls, element: str) -> IO.NodeOutput:
return IO.NodeOutput(element)
class KlingExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
@ -3364,7 +3317,6 @@ class KlingExtension(ComfyExtension):
KlingVideoNode,
KlingFirstLastFrameNode,
KlingAvatarNode,
KlingElementSelector,
]

View File

@ -202,14 +202,11 @@ class JoinImageWithAlpha(io.ComfyNode):
@classmethod
def execute(cls, image: torch.Tensor, alpha: torch.Tensor) -> io.NodeOutput:
batch_size = min(len(image), len(alpha))
out_images = []
batch_size = max(len(image), len(alpha))
alpha = 1.0 - resize_mask(alpha, image.shape[1:])
for i in range(batch_size):
out_images.append(torch.cat((image[i][:,:,:3], alpha[i].unsqueeze(2)), dim=2))
return io.NodeOutput(torch.stack(out_images))
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))
class CompositingExtension(ComfyExtension):

View File

@ -49,7 +49,7 @@ class Int(io.ComfyNode):
display_name="Int",
category="utils/primitive",
inputs=[
io.Int.Input("value", min=-sys.maxsize, max=sys.maxsize, control_after_generate=True),
io.Int.Input("value", min=-sys.maxsize, max=sys.maxsize, control_after_generate=io.ControlAfterGenerate.fixed),
],
outputs=[io.Int.Output()],
)

View File

@ -1016,10 +1016,6 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None):
if isinstance(input_type, list) or input_type == io.Combo.io_type:
if input_type == io.Combo.io_type:
# Skip validation for combos with remote options — options
# are fetched client-side and not available on the server.
if extra_info.get("remote_combo"):
continue
combo_options = extra_info.get("options", [])
else:
combo_options = input_type

View File

@ -1754,57 +1754,49 @@ class LoadImage:
return True
class LoadImageMask:
class LoadImageMask(LoadImage):
ESSENTIALS_CATEGORY = "Image Tools"
SEARCH_ALIASES = ["import mask", "alpha mask", "channel mask"]
_color_channels = ["alpha", "red", "green", "blue"]
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
return {"required":
{"image": (sorted(files), {"image_upload": True}),
"channel": (s._color_channels, ), }
}
types = super().INPUT_TYPES()
return {
"required": {
**types["required"],
"channel": (s._color_channels, )
}
}
CATEGORY = "mask"
RETURN_TYPES = ("MASK",)
FUNCTION = "load_image"
def load_image(self, image, channel):
image_path = folder_paths.get_annotated_filepath(image)
i = node_helpers.pillow(Image.open, image_path)
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.getbands() != ("R", "G", "B", "A"):
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
i = i.convert("RGBA")
mask = None
FUNCTION = "load_image_mask"
def load_image_mask(self, image, channel):
image_tensor, mask_tensor = super().load_image(image)
c = channel[0].upper()
if c in i.getbands():
mask = np.array(i.getchannel(c)).astype(np.float32) / 255.0
mask = torch.from_numpy(mask)
if c == 'A':
mask = 1. - mask
if c == 'A':
return (mask_tensor,)
channel_idx = {'R': 0, 'G': 1, 'B': 2}.get(c, 0)
if channel_idx < image_tensor.shape[-1]:
return (image_tensor[..., channel_idx].clone(),)
else:
mask = torch.zeros((64,64), dtype=torch.float32, device="cpu")
return (mask.unsqueeze(0),)
empty_mask = torch.zeros(
image_tensor.shape[:-1],
dtype=image_tensor.dtype,
device=image_tensor.device
)
return (empty_mask,)
@classmethod
def IS_CHANGED(s, image, channel):
image_path = folder_paths.get_annotated_filepath(image)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
m.update(f.read())
return m.digest().hex()
@classmethod
def VALIDATE_INPUTS(s, image):
if not folder_paths.exists_annotated_filepath(image):
return "Invalid image file: {}".format(image)
return True
return super().IS_CHANGED(image)
class LoadImageOutput(LoadImage):

View File

@ -1,3 +1,4 @@
import errno
import os
import sys
import asyncio
@ -1245,7 +1246,13 @@ class PromptServer():
address = addr[0]
port = addr[1]
site = web.TCPSite(runner, address, port, ssl_context=ssl_ctx)
await site.start()
try:
await site.start()
except OSError as e:
if e.errno == errno.EADDRINUSE:
logging.error(f"Port {port} is already in use on address {address}. Please close the other application or use a different port with --port.")
raise SystemExit(1)
raise
if not hasattr(self, 'address'):
self.address = address #TODO: remove this

View File

@ -1,139 +0,0 @@
import pytest
from comfy_api.latest._io import (
Combo,
RemoteComboOptions,
RemoteItemSchema,
RemoteOptions,
)
def _schema(**overrides):
defaults = dict(value_field="id", label_field="name")
return RemoteItemSchema(**{**defaults, **overrides})
def _combo(**overrides):
defaults = dict(route="/proxy/foo", item_schema=_schema())
return RemoteComboOptions(**{**defaults, **overrides})
def test_item_schema_defaults_accepted():
d = _schema().as_dict()
assert d == {"value_field": "id", "label_field": "name", "preview_type": "image"}
def test_item_schema_full_config_accepted():
d = _schema(
preview_url_field="preview",
preview_type="audio",
description_field="desc",
search_fields=["first", "last", "profile.email"],
).as_dict()
assert d["preview_type"] == "audio"
assert d["search_fields"] == ["first", "last", "profile.email"]
@pytest.mark.parametrize(
"bad_fields",
[
["{first} {last}"],
["name", "{age}"],
["leading{"],
["trailing}"],
],
)
def test_item_schema_rejects_template_strings_in_search_fields(bad_fields):
with pytest.raises(ValueError, match="search_fields"):
_schema(search_fields=bad_fields)
@pytest.mark.parametrize("bad_preview_type", ["middle", "IMAGE", "", "gif"])
def test_item_schema_rejects_unknown_preview_type(bad_preview_type):
with pytest.raises(ValueError, match="preview_type"):
_schema(preview_type=bad_preview_type)
def test_combo_options_minimal_accepted():
d = _combo().as_dict()
assert d["route"] == "/proxy/foo"
assert d["refresh_button"] is True
assert "item_schema" in d
@pytest.mark.parametrize(
"route",
[
"/proxy/foo",
"/voices",
],
)
def test_combo_options_accepts_valid_routes(route):
_combo(route=route)
@pytest.mark.parametrize(
"route",
[
"",
"api.example.com/voices",
"voices",
"ftp-no-scheme",
"http://localhost:9000/voices",
"https://api.example.com/v1/voices",
],
)
def test_combo_options_rejects_non_relative_routes(route):
with pytest.raises(ValueError, match="'route'"):
_combo(route=route)
@pytest.mark.parametrize("bad_auto_select", ["middle", "FIRST", "", "firstlast"])
def test_combo_options_rejects_unknown_auto_select(bad_auto_select):
with pytest.raises(ValueError, match="auto_select"):
_combo(auto_select=bad_auto_select)
@pytest.mark.parametrize("bad_refresh", [1, 127])
def test_combo_options_refresh_in_forbidden_range_rejected(bad_refresh):
with pytest.raises(ValueError, match="refresh"):
_combo(refresh=bad_refresh)
@pytest.mark.parametrize("ok_refresh", [0, -1, 128])
def test_combo_options_refresh_valid_values_accepted(ok_refresh):
_combo(refresh=ok_refresh)
def test_combo_options_timeout_negative_rejected():
with pytest.raises(ValueError, match="timeout"):
_combo(timeout=-1)
def test_combo_options_max_retries_negative_rejected():
with pytest.raises(ValueError, match="max_retries"):
_combo(max_retries=-1)
def test_combo_options_as_dict_prunes_none_fields():
d = _combo().as_dict()
for pruned in ("response_key", "refresh", "timeout", "max_retries", "auto_select"):
assert pruned not in d
def test_combo_input_accepts_remote_combo_alone():
Combo.Input("voice", remote_combo=_combo())
def test_combo_input_rejects_remote_plus_remote_combo():
with pytest.raises(ValueError, match="remote.*remote_combo"):
Combo.Input(
"voice",
remote=RemoteOptions(route="/r", refresh_button=True),
remote_combo=_combo(),
)
def test_combo_input_rejects_options_plus_remote_combo():
with pytest.raises(ValueError, match="options.*remote_combo"):
Combo.Input("voice", options=["a", "b"], remote_combo=_combo())