Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ history.

### Fixed

- Converting a UI workflow to API format keeps a record widget's saved value,
such as `ImageCropV2.crop_region`, instead of replacing it with the schema
default.
- `comfy jobs watch` no longer dies with a traceback when the ComfyUI server
accepts a connection and hangs up before answering (a restarting server, a
reset connection). That surfaced as a raw `http.client.RemoteDisconnected`,
Expand Down
6 changes: 3 additions & 3 deletions comfy_cli/workflow_to_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2386,7 +2386,9 @@ def emit(name: str, value: Any) -> Any:
if not isinstance(widget_values, list):
return {}

if any(isinstance(v, dict) for v in widget_values):
schema_widgets = any(_is_widget_input(spec)[0] for spec in input_specs) if schema else False
# Any schema-declared widget makes this list positional; self-naming dict layouts require no declared widgets.
if not schema_widgets and any(isinstance(v, dict) for v in widget_values):
_absorb_dict_widget_values(widget_values, out, link_inputs)
return out

Expand All @@ -2396,9 +2398,7 @@ def emit(name: str, value: Any) -> Any:
# a dynamic combo before a seed steal the seed's slot, e.g. Seedream's
# ``model`` before ``seed``). It also skips connection-only sub-inputs
# (e.g. ``COMFY_AUTOGROW_V3`` images) so they never consume a value slot.
schema = _schema_for(node_type, node, object_info)
pairs = _schema_widget_pairs(schema, widget_values) if schema else []
schema_widgets = any(_is_widget_input(spec)[0] for spec in input_specs) if schema else False
if pairs or schema_widgets:
for name, value in pairs:
if not name or name in link_inputs:
Expand Down
154 changes: 154 additions & 0 deletions tests/comfy_cli/test_workflow_to_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3165,3 +3165,157 @@ def test_a_socketless_input_with_a_default_uses_it(self, object_info_compare):

def test_a_saved_value_still_wins_over_the_placeholder(self, object_info_compare):
assert self._one("ColorNode", ["#123456"], object_info_compare)["color"] == "#123456"


class TestRecordWidgetValues:
@pytest.mark.parametrize("trailer", [[], [0, 0, 512, 512]])
def test_image_crop_region_is_not_replaced_by_the_default(self, trailer):
region = {"x": 0, "y": 0, "width": 1669, "height": 2688}
object_info = {
"ImageCropV2": {
"input": {
"required": {
"image": ["IMAGE", {}],
"crop_region": [
"BOUNDING_BOX",
{
"default": {"x": 0, "y": 0, "width": 512, "height": 512},
"socketless": True,
"component": "ImageCrop",
},
],
}
},
"input_order": {"required": ["image", "crop_region"]},
"output": ["IMAGE"],
}
}
ui = {
"nodes": [
_node(1, "ImageSource", outputs=[{"name": "IMAGE", "links": [1]}]),
_node(
338,
"ImageCropV2",
inputs=[{"name": "image", "link": 1}, {"name": "crop_region", "link": None}],
widgets=[region, *trailer],
),
],
"links": [[1, 1, 0, 338, 0, "IMAGE"]],
}
assert convert_ui_to_api(ui, object_info)["338"]["inputs"] == {
"image": ["1", 0],
"crop_region": region,
}

@pytest.fixture
def record_schema(self):
return {
"RecordNode": {
"input": {
"required": {
"before": ["FLOAT", {"default": 1.0}],
"record": ["RECORD", {"socketless": True}],
"after": ["STRING", {"default": "default"}],
}
},
"input_order": {"required": ["before", "record", "after"]},
"output": [],
}
}

@pytest.mark.parametrize(
"record",
[
{},
{"x": 4, "width": 32},
{"type": "rectangle"},
{"lora": "ordinary record data"},
{"type": "rectangle", "lora": "ordinary record data", "nested": {"points": [[1, 2]]}},
],
)
def test_record_members_do_not_choose_a_different_widget_layout(self, record_schema, record):
ui = {"nodes": [_node(2, "RecordNode", widgets=[0.25, record, "chosen"])], "links": []}
assert convert_ui_to_api(ui, record_schema)["2"]["inputs"] == {
"before": 0.25,
"record": record,
"after": "chosen",
}

def test_linked_record_keeps_following_widget_values_aligned(self, record_schema):
ui = {
"nodes": [
_node(1, "RecordSource", outputs=[{"name": "RECORD", "links": [1]}]),
_node(
2,
"RecordNode",
inputs=[{"name": "record", "link": 1}],
widgets=[0.25, {"type": "saved record"}, "chosen"],
),
],
"links": [[1, 1, 0, 2, 0, "RECORD"]],
}
assert convert_ui_to_api(ui, record_schema)["2"]["inputs"] == {
"before": 0.25,
"record": ["1", 0],
"after": "chosen",
}


class TestMixedWidgetLayouts:
@pytest.mark.parametrize("section", ["required", "optional"])
@pytest.mark.parametrize(
("widgets", "expected"),
[
([5, "hi", {"type": "Hdr"}], {"seed": 5, "text": "hi"}),
([{"type": "Hdr"}, 5, "hi"], {"seed": {"type": "Hdr"}, "text": 5}),
],
)
def test_declared_widgets_remain_positional_with_self_named_dicts(self, section, widgets, expected):
# Synthetic mixed layouts retain positional mapping, even when a dict precedes the declared values.
object_info = {
"MixedLayoutNode": {
"input": {section: {"seed": ["INT", {"default": 0}], "text": ["STRING", {"default": "default"}]}},
"input_order": {section: ["seed", "text"]},
"output": [],
}
}
ui = {"nodes": [_node(2, "MixedLayoutNode", widgets=widgets)], "links": []}
assert convert_ui_to_api(ui, object_info)["2"]["inputs"] == expected


class TestLegacyLoraWidgetValues:
@pytest.mark.parametrize("with_schema", [False, True])
@pytest.mark.parametrize("linked", [False, True])
@pytest.mark.parametrize("clip_strength", [0.0, 0.25])
def test_self_named_widgets_without_schema_slots_keep_their_layout(self, with_schema, linked, clip_strength):
node_type = "Power Lora Loader (rgthree)"
header = {"type": "PowerLoraLoaderHeaderWidget"}
first = {"on": True, "lora": "first.safetensors", "strength": 0.5, "strengthTwo": None}
second = {"on": False, "lora": "second.safetensors", "strength": 0.75, "strengthTwo": clip_strength}
node = _node(2, node_type, widgets=[header, first, second, {}, ""])
ui = {"nodes": [node], "links": []}
expected = {
"PowerLoraLoaderHeaderWidget": header,
"lora_1": {"on": True, "lora": "first.safetensors", "strength": 0.5},
"lora_2": second,
"➕ Add Lora": "",
}
if linked:
ui["nodes"].insert(0, _node(1, "Source", outputs=[{"links": [1]}, {"links": [2]}]))
node["inputs"] = [
{"name": "lora_1", "link": 1},
{"name": "PowerLoraLoaderHeaderWidget", "link": 2},
]
ui["links"] = [[1, 1, 0, 2, 0, "*"], [2, 1, 1, 2, 1, "*"]]
expected.update(lora_1=["1", 0], PowerLoraLoaderHeaderWidget=["1", 1])
object_info = (
{
node_type: {
"input": {"required": {}, "optional": {"model": ["MODEL"], "clip": ["CLIP"]}},
"output": ["MODEL", "CLIP"],
}
}
if with_schema
else {}
)
assert convert_ui_to_api(ui, object_info)["2"]["inputs"] == expected
Loading