mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-05-20 22:17:31 +08:00
Compare commits
5 Commits
0a9da4e5c9
...
59ba3b7df3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ba3b7df3 | ||
|
|
c33d26c283 | ||
|
|
f3ea976cba | ||
|
|
5538f62b0b | ||
|
|
defb663b94 |
@ -43,7 +43,67 @@ 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
|
||||
@ -70,6 +130,80 @@ 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"
|
||||
@ -359,11 +493,16 @@ 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):
|
||||
@ -375,6 +514,7 @@ 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):
|
||||
@ -385,6 +525,7 @@ 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):
|
||||
@ -2221,7 +2362,9 @@ class NodeReplace:
|
||||
__all__ = [
|
||||
"FolderType",
|
||||
"UploadType",
|
||||
"RemoteItemSchema",
|
||||
"RemoteOptions",
|
||||
"RemoteComboOptions",
|
||||
"NumberDisplay",
|
||||
"ControlAfterGenerate",
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -666,12 +666,13 @@ class ColorTransfer(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="ColorTransfer",
|
||||
display_name="Color Transfer",
|
||||
category="image/postprocessing",
|
||||
description="Match the colors of one image to another using various algorithms.",
|
||||
search_aliases=["color match", "color grading", "color correction", "match colors", "color transform", "mkl", "reinhard", "histogram"],
|
||||
inputs=[
|
||||
io.Image.Input("image_target", tooltip="Image(s) to apply the color transform to."),
|
||||
io.Image.Input("image_ref", optional=True, tooltip="Reference image(s) to match colors to. If not provided, processing is skipped"),
|
||||
io.Image.Input("image_ref", tooltip="Reference image(s) to match colors to."),
|
||||
io.Combo.Input("method", options=['reinhard_lab', 'mkl_lab', 'histogram'],),
|
||||
io.DynamicCombo.Input("source_stats",
|
||||
tooltip="per_frame: each frame matched to image_ref individually. uniform: pool stats across all source frames as baseline, match to image_ref. target_frame: use one chosen frame as the baseline for the transform to image_ref, applied uniformly to all frames (preserves relative differences)",
|
||||
|
||||
@ -1016,6 +1016,10 @@ 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
|
||||
|
||||
@ -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
|
||||
|
||||
139
tests-unit/comfy_api_test/remote_combo_options_test.py
Normal file
139
tests-unit/comfy_api_test/remote_combo_options_test.py
Normal file
@ -0,0 +1,139 @@
|
||||
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())
|
||||
Loading…
Reference in New Issue
Block a user