feat: wire a Media Bin reference into a placed node's file-upload widget

VHS_LoadVideo's "video" field (and LoadImage/LoadAudio/LoadVideo's own
file fields) are plain combo widgets, not wireable sockets, so a marked
reference video had no way in except a manual upload or path -- which
meant no way to force VHS_LoadVideo's own framerate/frame-cap controls
onto a reference clip without re-uploading it outside the Editor.

The node panel's combo fields for these known upload widgets now offer
the marked Media Bin references of the matching kind, using the same
"ref:<id>" values as Input sources. The builder resolves them to the
reference's filename at build time; an unresolvable or wrong-kind
reference is reported instead of silently defaulting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-18 16:06:53 +03:00
co-authored by Claude Sonnet 5
parent 063fa5a55b
commit e164e1ef70
4 changed files with 124 additions and 10 deletions
+28 -2
View File
@@ -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:<id>" / "ref#<kind>:<n>" 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"):
+19 -3
View File
@@ -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),
}
+32 -5
View File
@@ -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";
+45
View File
@@ -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."""