diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index 69c1ac2d..2736ad5d 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -26,8 +26,8 @@ from typing import Any, Optional from . import config from . import pipeline_wiring -from .nodes import (WIDGET_PRIMITIVES, connection_inputs, node_outputs, type_accepts, - widget_type_of, _combo_default, _combo_choices) +from .nodes import (WIDGET_PRIMITIVES, WIDGET_REFERENCE_FIELDS, connection_inputs, + node_outputs, type_accepts, widget_type_of, _combo_default, _combo_choices) # ── fixed core ──────────────────────────────────────────────────────────────── # logical id -> class_type @@ -593,6 +593,32 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No same_kind.sort(key=lambda r: int(r.get("index") or 0)) return same_kind[n - 1] if 1 <= n <= len(same_kind) else None + # 3a. widget-level references: a plain combo/upload field (VHS_LoadVideo's "video", + # LoadImage's "image", ...) set in the node panel to a Media Bin reference, using the + # same "ref:" / "ref#:" values as Input sources — but landing straight in + # the widget instead of wiring a socket, since WIDGET_REFERENCE_FIELDS entries aren't + # sockets at all (see nodes.connection_inputs, which skips them). + for s in slots: + sid = slot_node_id[s["id"]] + cls = s.get("node_class") + for wname, raw in (s.get("inputs") or {}).items(): + if not isinstance(raw, str) or not (raw.startswith("ref:") or raw.startswith("ref#")): + continue + want_kind = WIDGET_REFERENCE_FIELDS.get((cls, wname)) + if not want_kind: + continue + if raw.startswith("ref:"): + ref = references.get(raw[4:]) + else: + kind, _, num = raw[4:].partition(":") + ref = _reference_by_slot(kind, int(num)) if kind == want_kind and num.isdigit() else None + if ref and ref.get("filename") and (ref.get("kind") or "image") == want_kind: + graph[sid]["inputs"][wname] = ref["filename"] + report["wired"].append(f"Reference {want_kind} -> {sid}.{wname}") + else: + report["unsatisfied"].append( + f"{cls}.{wname}: reference '{raw}' is no longer in the media bin.") + def _reference_link(ref_id: str, want_type: Optional[str], where: str): ref = references.get(ref_id) if not ref or not ref.get("filename"): diff --git a/movie_editor/backend/nodes.py b/movie_editor/backend/nodes.py index 846828ea..c299659a 100644 --- a/movie_editor/backend/nodes.py +++ b/movie_editor/backend/nodes.py @@ -339,7 +339,20 @@ def node_outputs(node_def: dict) -> list[dict]: return out -def widget_inputs(node_def: dict) -> list[dict]: +# Widget-typed inputs that take a Media Bin reference's filename directly: a plain +# combo/upload field (VHS_LoadVideo's "video", LoadImage's "image", ...), not a wireable +# socket, so the Editor's node panel offers marked references of this kind alongside the +# node's own installed choices. Keyed by (class, field name) since a plain string combo +# carries no type info of its own to key off of. +WIDGET_REFERENCE_FIELDS: dict[tuple[str, str], str] = { + ("LoadImage", "image"): "image", + ("LoadAudio", "audio"): "audio", + ("LoadVideo", "file"): "video", + ("VHS_LoadVideo", "video"): "video", +} + + +def widget_inputs(node_def: dict, cls: str | None = None) -> list[dict]: """User-facing widgets for a node: combos (with options) and primitive fields. Skips graph-connection inputs (MODEL/CLIP/IMAGE/...) and forceInput sockets.""" out = [] @@ -354,6 +367,9 @@ def widget_inputs(node_def: dict) -> list[dict]: if opts.get("forceInput"): continue field = {"name": name, "required": group == "required", "options": opts} + ref_kind = WIDGET_REFERENCE_FIELDS.get((cls, name)) + if ref_kind: + field["ref_kind"] = ref_kind # A widget the node itself calls advanced: real, tweakable, and validated at # its default, so the panel folds it away rather than putting five of them # between the user and the file picker. @@ -408,7 +424,7 @@ def candidates(object_info: dict, role_key: str) -> list[dict]: "class": cls, "display_name": node_def.get("display_name", cls), "category": node_def.get("category", ""), - "inputs": widget_inputs(node_def), + "inputs": widget_inputs(node_def, cls), "outputs": node_outputs(node_def), "connection_inputs": connection_inputs(node_def), }) @@ -442,7 +458,7 @@ def describe_node(object_info: dict, cls: str) -> dict | None: "class": cls, "display_name": nd.get("display_name", cls), "category": nd.get("category", ""), - "inputs": widget_inputs(nd), + "inputs": widget_inputs(nd, cls), "outputs": node_outputs(nd), "connection_inputs": connection_inputs(nd), } diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index afcc970d..ca409054 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -595,6 +595,25 @@ return wrap; } + // Marked Media Bin references of exactly `refKind` ("image"/"audio"/"video"), as options + // for a widget-level reference field (spec.ref_kind) — e.g. VHS_LoadVideo's "video" combo. + // Kept a strict kind match (not typeAccepts cross-kind, like referenceSources() uses for + // real sockets): a widget takes the reference's filename verbatim, with no loader in + // between to bridge a video into an image-only field the way a real IMAGE socket can. + function widgetRefOptions(refKind) { + const st = window.Store?.get() || {}; + const marks = st.project?.references || []; + const bin = st.mediaBin || []; + const out = []; + marks.forEach((id) => { + const m = bin.find((x) => x.id === id); + if (!m || (m.kind || "image") !== refKind) return; + const n = window.References.referenceNumber(marks, bin, id); + out.push({ value: `ref:${id}`, label: `R${n} · ${m.name}` }); + }); + return out; + } + function widgetField(spec, value, onChange) { if (spec.kind === "list") return listField(spec, value, onChange); const wrap = el("label", "field"); @@ -602,12 +621,20 @@ let ctrl; if (spec.kind === "combo") { ctrl = el("select"); + const refOpts = spec.ref_kind ? widgetRefOptions(spec.ref_kind) : []; + if (refOpts.length) { + const og = el("optgroup"); og.label = `Media Bin reference (${spec.ref_kind})`; + refOpts.forEach((o) => { const opt = el("option", null, o.label); opt.value = o.value; if (o.value === value) opt.selected = true; og.append(opt); }); + ctrl.append(og); + } (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]); + if ((!spec.choices || !spec.choices.length) && !refOpts.length) { ctrl.append(el("option", null, "(none installed)")); ctrl.disabled = true; } + // A saved value that no longer exists (renamed/removed file, or an unmarked + // reference) 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) && !refOpts.some((o) => o.value === value)) + onChange(spec.choices[0] ?? refOpts[0]?.value); 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 e08dbaeb..1b41141f 100644 --- a/movie_editor/tests/test_builder.py +++ b/movie_editor/tests/test_builder.py @@ -1070,6 +1070,51 @@ def test_prevvideo_retrieval_failure_is_declared_not_silent(): for u in report["unsatisfied"]) +# ── widget-level references (a manually placed VHS_LoadVideo's "video" combo, not a +# wireable socket) ────────────────────────────────────────────────────────────── + + +def test_a_reference_set_on_a_widget_combo_resolves_to_its_filename(): + models = {"slots": [ + {"id": "lv", "node_class": "VHS_LoadVideo", "inputs": {"video": "ref:m1"}, "wires": {}}, + ]} + params = _ref_params({"id": "m1", "kind": "video", "name": "clip.mp4", + "filename": "funpack_movie_m1.mp4"}) + graph, report = builder.build(VIDEO_OI, models, params) + assert graph["slot_lv"]["inputs"]["video"] == "funpack_movie_m1.mp4" + assert any("video" in w.lower() and "slot_lv.video" in w for w in report["wired"]) + + +def test_a_widget_reference_of_the_wrong_kind_is_not_wired(): + """VHS_LoadVideo's "video" field only takes a video reference — an image marked in the + bin must not land there just because it happens to be the only reference around.""" + models = {"slots": [ + {"id": "lv", "node_class": "VHS_LoadVideo", "inputs": {"video": "ref:m1"}, "wires": {}}, + ]} + params = _ref_params({"id": "m1", "kind": "image", "name": "face.png", + "filename": "funpack_movie_m1.png"}) + graph, report = builder.build(VIDEO_OI, models, params) + assert graph["slot_lv"]["inputs"]["video"] != "funpack_movie_m1.png" + assert any("VHS_LoadVideo.video" in u for u in report["unsatisfied"]) + + +def test_a_widget_reference_that_is_gone_reports_instead_of_silently_defaulting(): + models = {"slots": [ + {"id": "lv", "node_class": "VHS_LoadVideo", "inputs": {"video": "ref:missing"}, "wires": {}}, + ]} + graph, report = builder.build(VIDEO_OI, models, _ref_params()) + assert graph["slot_lv"]["inputs"]["video"] == "a.mp4" # falls back to the node's own default + assert any("VHS_LoadVideo.video" in u for u in report["unsatisfied"]) + + +def test_a_plain_widget_value_that_is_not_a_reference_is_left_alone(): + models = {"slots": [ + {"id": "lv", "node_class": "VHS_LoadVideo", "inputs": {"video": "a.mp4"}, "wires": {}}, + ]} + graph, _report = builder.build(VIDEO_OI, models, _ref_params()) + assert graph["slot_lv"]["inputs"]["video"] == "a.mp4" + + def test_the_loader_matches_what_the_destination_socket_asks_for(): """An audio reference feeding an AUDIO socket needs LoadAudio, not LoadImage — the loader is chosen by the socket's type, not by guessing from the file."""