From 3eb808151841052604d058bea8bca69df1a64fcc Mon Sep 17 00:00:00 2001 From: allenflux <26239570+allenflux@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:03:33 +0700 Subject: [PATCH 1/2] fix(workflow): preserve record widget values --- CHANGELOG.md | 3 + comfy_cli/workflow_to_api.py | 5 +- tests/comfy_cli/test_workflow_to_api.py | 132 ++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 888ed6578..a969fd1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,9 @@ history. ### Fixed +- Preserve record-valued widgets such as `ImageCropV2.crop_region` during + UI-to-API workflow conversion instead of replacing them with schema defaults. + - `comfy deploy run --wait` no longer loses a finished job's outputs to a 401 when the sign-in token it started with expires mid-run. The token lasts fifteen minutes, and the run sent the one it started with for every request diff --git a/comfy_cli/workflow_to_api.py b/comfy_cli/workflow_to_api.py index 4614b57fe..8d1ad8f3a 100644 --- a/comfy_cli/workflow_to_api.py +++ b/comfy_cli/workflow_to_api.py @@ -1671,7 +1671,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 + # Declared widget slots can hold records; keep the self-naming layout for nodes without those slots. + 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 @@ -1683,7 +1685,6 @@ def emit(name: str, value: Any) -> Any: # (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 9a28c3b83..17ec8263e 100644 --- a/tests/comfy_cli/test_workflow_to_api.py +++ b/tests/comfy_cli/test_workflow_to_api.py @@ -3075,3 +3075,135 @@ 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 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 From 1da7e7b4c2b388ade619e66b4b151915d1543d6c Mon Sep 17 00:00:00 2001 From: allenflux <26239570+allenflux@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:59:30 +0700 Subject: [PATCH 2/2] refactor(workflow): clarify record widget mapping --- CHANGELOG.md | 5 +++-- comfy_cli/workflow_to_api.py | 3 +-- tests/comfy_cli/test_workflow_to_api.py | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bee32b6e..836dd09df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,8 +135,9 @@ history. ### Fixed -- Preserve record-valued widgets such as `ImageCropV2.crop_region` during - UI-to-API workflow conversion instead of replacing them with schema defaults. +- 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 run` no longer reports a failure to write its own job-state file as a lost connection to the ComfyUI server. The state file is a convenience record, written after the server has already accepted the prompt, so a diff --git a/comfy_cli/workflow_to_api.py b/comfy_cli/workflow_to_api.py index 8d1ad8f3a..a87a1b726 100644 --- a/comfy_cli/workflow_to_api.py +++ b/comfy_cli/workflow_to_api.py @@ -1672,7 +1672,7 @@ def emit(name: str, value: Any) -> Any: return {} schema_widgets = any(_is_widget_input(spec)[0] for spec in input_specs) if schema else False - # Declared widget slots can hold records; keep the self-naming layout for nodes without those slots. + # 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 @@ -1683,7 +1683,6 @@ 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 [] if pairs or schema_widgets: for name, value in pairs: diff --git a/tests/comfy_cli/test_workflow_to_api.py b/tests/comfy_cli/test_workflow_to_api.py index 17ec8263e..452e586ef 100644 --- a/tests/comfy_cli/test_workflow_to_api.py +++ b/tests/comfy_cli/test_workflow_to_api.py @@ -3171,6 +3171,28 @@ def test_linked_record_keeps_following_widget_values_aligned(self, record_schema } +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])