diff --git a/CHANGELOG.md b/CHANGELOG.md index d3510c731..1c533db2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, diff --git a/comfy_cli/workflow_to_api.py b/comfy_cli/workflow_to_api.py index 129017eb6..fc0ce67bd 100644 --- a/comfy_cli/workflow_to_api.py +++ b/comfy_cli/workflow_to_api.py @@ -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 @@ -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: diff --git a/tests/comfy_cli/test_workflow_to_api.py b/tests/comfy_cli/test_workflow_to_api.py index 7b094e568..6cbae6871 100644 --- a/tests/comfy_cli/test_workflow_to_api.py +++ b/tests/comfy_cli/test_workflow_to_api.py @@ -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