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:
co-authored by
Claude Sonnet 5
parent
063fa5a55b
commit
e164e1ef70
@@ -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"):
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user