From cb25c14e8def6dde778556e122550bd1015caf3f Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:23:31 +0300 Subject: [PATCH] Movie Editor: coerce stale LoRA/combo slot values to live choices A renamed or removed file (e.g. an old distilled LoRA) left a slot's saved combo value out of sync with object_info's current choices. The Models menu select then showed the live first choice (browser default for "no matching option") while the saved config still held the stale string, which the builder passed straight through to ComfyUI and failed validation. Coerce in both places: the builder falls back unknown combo values to the node's current default/first choice, and the Models menu select persists what it actually displays instead of leaving the stale value on disk. --- movie_editor/backend/builder.py | 38 +++++++++++++++++++++++++++--- movie_editor/frontend/models.js | 4 ++++ movie_editor/tests/test_builder.py | 10 ++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) 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": [