diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index b2ff1c63..d97aae73 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -24,7 +24,7 @@ from typing import Any, Optional from . import config from . import pipeline_wiring -from .nodes import WIDGET_PRIMITIVES, connection_inputs, node_outputs, _combo_default +from .nodes import WIDGET_PRIMITIVES, connection_inputs, node_outputs, _combo_default, _combo_choices # ── fixed core ──────────────────────────────────────────────────────────────── # logical id -> class_type @@ -172,6 +172,32 @@ def _widget_defaults(node_def: Optional[dict]) -> dict: return out +def _widget_choices(node_def: Optional[dict]) -> dict: + """{name: [choices]} for combo widgets, per the LIVE object_info (e.g. installed + LoRA files). Used to coerce a saved slot value that no longer exists (renamed/ + removed file) back to a current choice instead of being sent to ComfyUI as-is.""" + out = {} + if not node_def: + return out + inp = node_def.get("input") or {} + for group in ("required", "optional"): + for name, spec in (inp.get(group) or {}).items(): + if not isinstance(spec, (list, tuple)) or not spec: + continue + t = spec[0] + opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {} + if opts.get("forceInput"): + continue + if isinstance(t, list): + if t: + out[name] = t + elif isinstance(t, str) and "COMBO" in t.upper(): + choices = _combo_choices(opts) + if choices: + out[name] = choices + return out + + # ── reference workflow ──────────────────────────────────────────────────────── def load_reference() -> dict: @@ -311,12 +337,18 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No slot_def[s["id"]] = nd inputs = _widget_defaults(nd) defaults = dict(inputs) + choices = _widget_choices(nd) inputs.update(s.get("inputs") or {}) - # Coerce stale/invalid combo values (e.g. a saved "[object Object]" or a dict from - # an earlier bug) back to the node's default key. + # Coerce stale/invalid combo values back to the node's current default key: + # a saved "[object Object]" or dict from an earlier bug, or a value (e.g. a + # LoRA filename) that was renamed/removed and no longer appears in the live + # choices list — the Models menu shows the live choices, so the saved value + # should fall back to match what the selector actually displays. for wname, wval in list(inputs.items()): if isinstance(wval, dict) or wval == "[object Object]": inputs[wname] = defaults.get(wname, "") + elif wname in choices and wval not in choices[wname]: + inputs[wname] = defaults.get(wname, choices[wname][0]) graph[sid] = {"class_type": cls, "inputs": inputs} if cls not in object_info: msg = f"Slot node '{cls}' is not installed in ComfyUI." diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index f2e29918..517cb9e3 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -240,6 +240,10 @@ ctrl = el("select"); (spec.choices || []).forEach((c) => { const o = el("option", null, String(c)); o.value = c; if (c === value) o.selected = true; ctrl.append(o); }); if (!spec.choices || !spec.choices.length) { ctrl.append(el("option", null, "(none installed)")); ctrl.disabled = true; } + // A saved value that no longer exists (renamed/removed file) leaves nothing + // selected — the browser then shows the first option without persisting it, + // so the stale value silently goes to generation. Persist what's displayed. + else if (value != null && !spec.choices.includes(value)) onChange(spec.choices[0]); ctrl.onchange = () => onChange(ctrl.value); } else if (spec.kind === "boolean") { ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!value; ctrl.style.width = "auto"; diff --git a/movie_editor/tests/test_builder.py b/movie_editor/tests/test_builder.py index 5219fd01..2099cec9 100644 --- a/movie_editor/tests/test_builder.py +++ b/movie_editor/tests/test_builder.py @@ -115,6 +115,16 @@ def test_explicit_wires_and_autowire(): assert report["ambiguous"] == [] +def test_stale_combo_value_coerced_to_live_choice(): + # "old.safetensors" was renamed/removed; the live spec only has "m.safetensors". + models = {"full_control": True, "slots": [ + {"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "old.safetensors"}, + "wires": {"MODEL": "port:FunPackStudio.model"}}, + ]} + graph, _ = builder.build(OI, models, PARAMS) + assert graph["slot_u"]["inputs"]["unet_name"] == "m.safetensors" + + def test_linked_inputs_drive_multiple_node_values(): models = { "slots": [