Models: treat V3 MultiType widgets as widgets, not required sockets
LTXVEmptyLatentAudio.frame_rate arrives from object_info as
("FLOAT,INT", {"widgetType": "INT", ...}) — a V3 MultiType wrapped around an
int widget. The editor split types on exact string match, so the union looked
like an unknown connection type: the field vanished from the node's widgets,
turned into a required input source no node could satisfy, and blocked
generation with 'has no source'.
Classification now goes through nodes.widget_type_of(): an input whose members
are all widget types (or that carries a widgetType hint) is a widget, rendered
and defaulted as its widget member. Genuine unions ("IMAGE,MASK") stay sockets
and match a producer of any member via type_accepts(), used by auto-wire,
bypass passthrough, the source/destination pickers and the image-target list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3222fbcb31
commit
5af0a1b5d3
@@ -25,7 +25,8 @@ from typing import Any, Optional
|
||||
|
||||
from . import config
|
||||
from . import pipeline_wiring
|
||||
from .nodes import WIDGET_PRIMITIVES, connection_inputs, node_outputs, _combo_default, _combo_choices
|
||||
from .nodes import (WIDGET_PRIMITIVES, connection_inputs, node_outputs, type_accepts,
|
||||
widget_type_of, _combo_default, _combo_choices)
|
||||
|
||||
# ── fixed core ────────────────────────────────────────────────────────────────
|
||||
# logical id -> class_type
|
||||
@@ -116,7 +117,7 @@ def _ordered_widget_names(node_def: dict) -> list[str]:
|
||||
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
|
||||
if opts.get("forceInput"):
|
||||
continue # forceInput STRING is a socket, never a widget value
|
||||
if isinstance(t, list) or t in WIDGET_PRIMITIVES:
|
||||
if isinstance(t, list) or widget_type_of(t, opts) in WIDGET_PRIMITIVES:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
@@ -158,18 +159,19 @@ def _widget_defaults(node_def: Optional[dict]) -> dict:
|
||||
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
|
||||
if opts.get("forceInput"):
|
||||
continue
|
||||
wt = widget_type_of(t, opts) if isinstance(t, str) else None
|
||||
if isinstance(t, list):
|
||||
out[name] = opts.get("default", t[0] if t else None)
|
||||
elif isinstance(t, str) and "COMBO" in t.upper():
|
||||
elif wt and "COMBO" in wt.upper():
|
||||
# "COMBO" or a V3 dynamic combo (COMFY_DYNAMICCOMBO_V3): emit the selected
|
||||
# key string (options may be {"key": ...} dicts), not the option object.
|
||||
out[name] = _combo_default(opts)
|
||||
elif t in WIDGET_PRIMITIVES:
|
||||
elif wt in WIDGET_PRIMITIVES:
|
||||
# Always emit a value for every widget — ComfyUI's frontend does, and a
|
||||
# required widget with no declared default (e.g. ImageTransform's bboxes)
|
||||
# otherwise goes missing and ComfyUI rejects the prompt. Fall back to a
|
||||
# type-appropriate empty so generation isn't blocked.
|
||||
out[name] = opts.get("default", _WIDGET_EMPTY.get(t, ""))
|
||||
out[name] = opts.get("default", _WIDGET_EMPTY.get(wt, ""))
|
||||
return out
|
||||
|
||||
|
||||
@@ -192,7 +194,7 @@ def _widget_choices(node_def: Optional[dict]) -> dict:
|
||||
if isinstance(t, list):
|
||||
if t:
|
||||
out[name] = t
|
||||
elif isinstance(t, str) and "COMBO" in t.upper():
|
||||
elif isinstance(t, str) and "COMBO" in (widget_type_of(t, opts) or "").upper():
|
||||
choices = _combo_choices(opts)
|
||||
if choices:
|
||||
out[name] = choices
|
||||
@@ -627,7 +629,7 @@ def core_graph(object_info: dict, models_config: dict | None = None) -> list[dic
|
||||
|
||||
def _options(t, builtin_label, self_cid):
|
||||
opts = [{"value": "", "label": f"built-in: {builtin_label}"}]
|
||||
for val, lbl in producers.get(t, []):
|
||||
for val, lbl in _matching_producers(producers, t):
|
||||
if val.startswith(f"core:{self_cid}:"):
|
||||
continue
|
||||
opts.append({"value": val, "label": lbl})
|
||||
@@ -714,6 +716,18 @@ def _producers(graph, slots, slot_node_id, slot_def, object_info):
|
||||
return out
|
||||
|
||||
|
||||
def _matching_producers(producers: dict, t: str) -> list:
|
||||
"""Producers that can feed an input of type `t`. A union type ("IMAGE,MASK") is fed by
|
||||
any of its members, so collect across them instead of matching the string exactly."""
|
||||
if t in producers:
|
||||
return producers[t]
|
||||
out = []
|
||||
for pt, lst in producers.items():
|
||||
if type_accepts(t, pt):
|
||||
out.extend(lst)
|
||||
return out
|
||||
|
||||
|
||||
def _reaches_upstream(graph, start, goal):
|
||||
"""True if `goal` is upstream of `start` — i.e. `start` transitively consumes `goal`'s
|
||||
output. Used to decide whether wiring `start`'s output into a node would close a loop."""
|
||||
@@ -838,7 +852,7 @@ def _autowire(graph, slots, slot_node_id, slot_def, object_info, producers, repo
|
||||
continue
|
||||
if isinstance(node["inputs"].get(inp), list):
|
||||
continue # already wired (explicit/core)
|
||||
raw = [p for p in producers.get(t, []) if p[0] != node_id]
|
||||
raw = [p for p in _matching_producers(producers, t) if p[0] != node_id]
|
||||
# Never auto-wire a source that already depends on this node — that closes a loop and
|
||||
# ComfyUI rejects the whole graph. Drop such candidates so auto-wire stays acyclic.
|
||||
cands = [p for p in raw if not _reaches_upstream(graph, p[0], node_id)]
|
||||
@@ -880,14 +894,13 @@ def _apply_bypass(graph, slots, slot_node_id, slot_def, report):
|
||||
continue
|
||||
nd = slot_def.get(s["id"]) or {}
|
||||
outs = node_outputs(nd)
|
||||
by_type: dict[str, list[str]] = {}
|
||||
for ci in connection_inputs(nd):
|
||||
by_type.setdefault(ci["type"], []).append(ci["name"])
|
||||
cis = connection_inputs(nd)
|
||||
passthrough = {}
|
||||
ok = True
|
||||
for i, o in enumerate(outs):
|
||||
names = by_type.get(o["type"])
|
||||
if not names or len(names) != 1:
|
||||
# A union-typed input ("IMAGE,MASK") can carry any of its members through.
|
||||
names = [ci["name"] for ci in cis if type_accepts(ci["type"], o["type"])]
|
||||
if len(names) != 1:
|
||||
ok = False
|
||||
break
|
||||
passthrough[i] = graph[sid]["inputs"].get(names[0])
|
||||
|
||||
@@ -36,6 +36,47 @@ def _is_widget_type(t) -> bool:
|
||||
return isinstance(t, str) and (t in _WIDGET_TYPES or _is_combo_type(t))
|
||||
|
||||
|
||||
def type_parts(t) -> list[str]:
|
||||
"""Member types of a ComfyUI type string. A V3 MultiType input serializes its members
|
||||
comma-joined — "FLOAT,INT" (an int widget that also accepts a float link), "IMAGE,MASK"
|
||||
(a socket taking either) — so a single string can name several types."""
|
||||
if not isinstance(t, str):
|
||||
return []
|
||||
return [p.strip() for p in t.split(",") if p.strip()]
|
||||
|
||||
|
||||
def widget_type_of(t, opts: dict | None = None) -> str | None:
|
||||
"""The widget type an input renders as, or None if it is a pure graph socket.
|
||||
|
||||
A V3 MultiType wrapped around a widget input (io.MultiType.Input(io.Int.Input(...), ...))
|
||||
is a WIDGET in ComfyUI's frontend, not a socket: it carries "widgetType" in its options
|
||||
and lists the widget's own type first, e.g. LTXVEmptyLatentAudio.frame_rate arriving as
|
||||
("FLOAT,INT", {"widgetType": "INT", "default": 25}). Treating it as a required socket
|
||||
would demand a source for a field the user simply types into."""
|
||||
opts = opts or {}
|
||||
wt = opts.get("widgetType")
|
||||
if _is_widget_type(wt):
|
||||
return wt
|
||||
parts = type_parts(t)
|
||||
if parts and all(_is_widget_type(p) for p in parts):
|
||||
return parts[0]
|
||||
return None
|
||||
|
||||
|
||||
def link_types(t) -> list[str]:
|
||||
"""The graph-connection member types of a type string (widget members dropped)."""
|
||||
return [_normalize_type(p) for p in type_parts(t) if not _is_widget_type(p)]
|
||||
|
||||
|
||||
def type_accepts(input_type: str, output_type: str) -> bool:
|
||||
"""Whether an output of `output_type` can feed an input of `input_type`. Either side may
|
||||
be a union, so they match when any member type is shared."""
|
||||
a, b = type_parts(input_type), type_parts(output_type)
|
||||
if not a or not b:
|
||||
return input_type == output_type
|
||||
return bool({_normalize_type(x) for x in a} & {_normalize_type(y) for y in b})
|
||||
|
||||
|
||||
def _combo_choices(opts: dict) -> list:
|
||||
"""Normalise combo options to plain string/int choices. V3 dynamic combos express
|
||||
options as dicts {"key": <value>, "inputs": [...]}; standard combos are list[str]."""
|
||||
@@ -120,8 +161,9 @@ def _all_input_types(node_def: dict) -> list[str]:
|
||||
for group in ("required", "optional"):
|
||||
for spec in (inp.get(group) or {}).values():
|
||||
t = spec[0] if isinstance(spec, list) and spec else None
|
||||
if isinstance(t, str) and t not in _WIDGET_TYPES:
|
||||
types.append(_normalize_type(t))
|
||||
opts = spec[1] if isinstance(spec, list) and len(spec) > 1 and isinstance(spec[1], dict) else {}
|
||||
if isinstance(t, str) and not widget_type_of(t, opts):
|
||||
types.extend(link_types(t))
|
||||
return types
|
||||
|
||||
|
||||
@@ -153,15 +195,17 @@ def connection_inputs(node_def: dict) -> list[dict]:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
|
||||
if _is_widget_type(t):
|
||||
wt = widget_type_of(t, opts)
|
||||
if wt:
|
||||
# A widget-typed input is a SOCKET (wireable) only when forceInput is set —
|
||||
# e.g. refinement_key_input is ("STRING", {"forceInput": True}). It always
|
||||
# has a widget fallback, so it's NEVER auto-required (it must not block
|
||||
# generation when left unwired). Plain widgets (incl. V3 dynamic combos
|
||||
# like COMFY_DYNAMICCOMBO_V3) are handled by widget_inputs, not here.
|
||||
# like COMFY_DYNAMICCOMBO_V3 and MultiType widgets like ("FLOAT,INT", ...))
|
||||
# are handled by widget_inputs, not here.
|
||||
if not opts.get("forceInput"):
|
||||
continue
|
||||
out.append({"name": name, "type": _normalize_type(t), "required": False})
|
||||
out.append({"name": name, "type": _normalize_type(wt), "required": False})
|
||||
continue
|
||||
# V3 nodes can mark a required-group input as optional via the flag.
|
||||
is_required = group == "required" and not opts.get("optional", False)
|
||||
@@ -201,6 +245,9 @@ def widget_inputs(node_def: dict) -> list[dict]:
|
||||
if opts.get("forceInput"):
|
||||
continue
|
||||
field = {"name": name, "required": group == "required", "options": opts}
|
||||
# A MultiType widget ("FLOAT,INT") renders as its widget member, not the union.
|
||||
if isinstance(t, str):
|
||||
t = widget_type_of(t, opts) or t
|
||||
if isinstance(t, list):
|
||||
# Old-style combo: t is the list of choices (e.g. folder_paths filenames).
|
||||
field["kind"] = "combo"
|
||||
@@ -307,7 +354,7 @@ def ports_from_input_types(label: str, node_key: str, input_types: dict) -> list
|
||||
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
|
||||
# Expose typed sockets, plus forceInput widget sockets (e.g. the STRING
|
||||
# refinement_key_input on Studio/Sampler) so they can be wired manually.
|
||||
if _is_widget_type(t) and not opts.get("forceInput"):
|
||||
if widget_type_of(t, opts) and not opts.get("forceInput"):
|
||||
continue
|
||||
ports.append({"id": f"{node_key}.{name}", "node": label, "input": name,
|
||||
"type": t, "label": f"{label} · {name}"})
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
function allowedSources(slot, ci) {
|
||||
const all = sources(slot, ci.type);
|
||||
if (!pipelineLocked()) return all;
|
||||
if (slot.role === "image_processing" && ci.name === "image" && ci.type === "IMAGE") {
|
||||
if (slot.role === "image_processing" && ci.name === "image" && typeAccepts(ci.type, "IMAGE")) {
|
||||
return all.filter((s) => !s.value || s.value === "timeline" || s.value.startsWith("out:"));
|
||||
}
|
||||
return all.filter((s) => !s.value || s.value.startsWith("out:") || s.value === "timeline");
|
||||
@@ -361,7 +361,7 @@
|
||||
// that feeds nothing must not be flagged or block generation.
|
||||
if (slotIsActive(slot)) (spec.connection_inputs || []).forEach((ci) => {
|
||||
if (!ci.required) return;
|
||||
if (ci.type === "IMAGE") return; // a scene/timeline image can always feed an IMAGE input
|
||||
if (typeAccepts(ci.type, "IMAGE")) return; // a scene/timeline image can always feed an IMAGE input
|
||||
const src = (slot.input_sources || {})[ci.name];
|
||||
if (src && src !== "auto") return; // explicitly sourced (incl. "timeline")
|
||||
const incoming = (config.slots || []).some((s2) =>
|
||||
@@ -546,16 +546,24 @@
|
||||
}
|
||||
|
||||
const _KIND2T = { int: "INT", float: "FLOAT", string: "STRING", boolean: "BOOLEAN" };
|
||||
// A ComfyUI type string can name several types at once — a V3 MultiType socket serializes
|
||||
// its members comma-joined ("IMAGE,MASK"). An output feeds an input when they share one.
|
||||
function typeAccepts(inputType, outputType) {
|
||||
if (inputType === outputType) return true;
|
||||
const parts = (t) => String(t || "").split(",").map((p) => p.trim()).filter(Boolean);
|
||||
const a = parts(inputType), b = parts(outputType);
|
||||
return a.some((x) => b.includes(x));
|
||||
}
|
||||
function destinations(slot, type) {
|
||||
const out = [{ value: "", label: "— unwired —" }];
|
||||
// Global editor outputs: wire your final video/audio producer here and the editor shows
|
||||
// it (works with or without the built-in pipeline). IMAGE -> video, AUDIO -> audio.
|
||||
if (type === "IMAGE") out.push({ value: "global:video", label: "🌐 Global video output (shown in editor)" });
|
||||
if (type === "AUDIO") out.push({ value: "global:audio", label: "🌐 Global audio output (shown in editor)" });
|
||||
ports.filter((p) => p.type === type).forEach((p) => out.push({ value: "port:" + p.id, label: p.label }));
|
||||
if (typeAccepts(type, "IMAGE")) out.push({ value: "global:video", label: "🌐 Global video output (shown in editor)" });
|
||||
if (typeAccepts(type, "AUDIO")) out.push({ value: "global:audio", label: "🌐 Global audio output (shown in editor)" });
|
||||
ports.filter((p) => typeAccepts(p.type, type)).forEach((p) => out.push({ value: "port:" + p.id, label: p.label }));
|
||||
config.slots.filter((s) => s.id !== slot.id).forEach((s2) => {
|
||||
const c2 = specFor(s2);
|
||||
(c2?.connection_inputs || []).filter((ci) => ci.type === type).forEach((ci) =>
|
||||
(c2?.connection_inputs || []).filter((ci) => typeAccepts(ci.type, type)).forEach((ci) =>
|
||||
out.push({ value: `node:${s2.id}:${ci.name}`, label: `${slotFullLabel(s2)} · ${ci.name}` }));
|
||||
// Widget inputs can also receive a connection (ComfyUI converts a widget to an
|
||||
// input when wired) — e.g. EmptyLatentVideo.width/height. Offer them as targets.
|
||||
@@ -569,12 +577,12 @@
|
||||
// Source IDs: "" = auto, "out:<slotId>:<outName>", "core:<coreId>:<outIdx>", "timeline" (IMAGE only).
|
||||
function sources(slot, type) {
|
||||
const out = [{ value: "", label: "(auto-wire)" }];
|
||||
if (type === "IMAGE") out.push({ value: "timeline", label: "Timeline (scene image)" });
|
||||
coreProducers.filter((p) => p.type === type).forEach((p) =>
|
||||
if (typeAccepts(type, "IMAGE")) out.push({ value: "timeline", label: "Timeline (scene image)" });
|
||||
coreProducers.filter((p) => typeAccepts(type, p.type)).forEach((p) =>
|
||||
out.push({ value: p.id, label: p.label }));
|
||||
config.slots.filter((s) => s.id !== slot.id).forEach((s2) => {
|
||||
const c2 = specFor(s2);
|
||||
(c2?.outputs || []).filter((o) => o.type === type).forEach((o) =>
|
||||
(c2?.outputs || []).filter((o) => typeAccepts(type, o.type)).forEach((o) =>
|
||||
out.push({ value: `out:${s2.id}:${o.name}`, label: `${slotFullLabel(s2)} → ${o.name}` }));
|
||||
});
|
||||
return out;
|
||||
|
||||
@@ -2264,14 +2264,14 @@ if web is not None and PromptServer is not None:
|
||||
oi = {}
|
||||
out = []
|
||||
for p in nodes.pipeline_ports(oi):
|
||||
if p.get("type") == "IMAGE":
|
||||
if nodes.type_accepts(p.get("type") or "", "IMAGE"):
|
||||
out.append({"value": "port:" + p["id"], "label": p["label"]})
|
||||
pid = req.query.get("pid")
|
||||
models_cfg = _project_models(projects.get(pid)) if pid else nodes.load_models()
|
||||
for slot in models_cfg.get("slots", []):
|
||||
nd = oi.get(slot.get("node_class")) or {}
|
||||
for ci in nodes.connection_inputs(nd):
|
||||
if ci["type"] == "IMAGE":
|
||||
if nodes.type_accepts(ci["type"], "IMAGE"):
|
||||
label = (slot.get("label") or slot.get("node_class")) + " · " + ci["name"]
|
||||
out.append({"value": f"node:{slot['id']}:{ci['name']}", "label": label})
|
||||
return web.json_response({"targets": out})
|
||||
|
||||
@@ -207,6 +207,36 @@ def test_ambiguous_type_is_reported_not_guessed():
|
||||
assert any("FunPackLTXAVSceneChainSampler" in a and ".vae" in a for a in report["ambiguous"])
|
||||
|
||||
|
||||
def test_multitype_widget_input_does_not_block_and_keeps_its_value():
|
||||
# LTXVEmptyLatentAudio.frame_rate arrives as a V3 MultiType widget ("FLOAT,INT" with a
|
||||
# widgetType hint). It is a field, not a socket: it must not demand a source (nothing
|
||||
# outputs "FLOAT,INT", so the run was blocked) and its value must reach the graph.
|
||||
oi = dict(OI)
|
||||
oi["LTXVEmptyLatentAudio"] = {
|
||||
"input": {"required": {
|
||||
"frames_number": ["INT", {"default": 97}],
|
||||
"frame_rate": ["FLOAT,INT", {"widgetType": "INT", "default": 25}],
|
||||
"audio_vae": ["VAE"],
|
||||
}},
|
||||
"output": ["LATENT"], "output_name": ["Latent"]}
|
||||
models = {"full_control": True, "slots": [
|
||||
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
|
||||
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {"CLIP": "port:FunPackStudio.clip"}},
|
||||
{"id": "v", "node_class": "VaeLoader", "inputs": {},
|
||||
"wires": {"VAE": "port:FunPackLTXAVSceneChainSampler.vae"}},
|
||||
{"id": "av", "node_class": "VaeLoader", "inputs": {},
|
||||
"wires": {"VAE": ["node:ae:audio_vae", "port:LTXVAudioVAEDecode.audio_vae"]}},
|
||||
{"id": "ae", "node_class": "LTXVEmptyLatentAudio", "inputs": {"frame_rate": 30},
|
||||
"wires": {"Latent": "port:LTXVConcatAVLatent.audio_latent"}},
|
||||
]}
|
||||
graph, report = builder.build(oi, models, PARAMS)
|
||||
assert report["blocking"] == []
|
||||
assert not any("frame_rate" in m for m in report["unsatisfied"] + report["ambiguous"])
|
||||
assert graph["slot_ae"]["inputs"]["frame_rate"] == 30 # user value, not a link
|
||||
assert graph["slot_ae"]["inputs"]["frames_number"] == 97 # default still emitted
|
||||
assert graph["slot_ae"]["inputs"]["audio_vae"] == ["slot_av", 0]
|
||||
|
||||
|
||||
def _loaders():
|
||||
return [
|
||||
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
|
||||
|
||||
@@ -154,6 +154,47 @@ def test_v1_combo_and_force_input():
|
||||
assert ci["blocks"] == "SELECTEDDITBLOCKS"
|
||||
|
||||
|
||||
def test_multitype_widget_is_a_widget_not_a_required_socket():
|
||||
"""A V3 MultiType built around a widget input serializes comma-joined with a widgetType
|
||||
hint — e.g. LTXVEmptyLatentAudio.frame_rate as ("FLOAT,INT", {"widgetType": "INT"}).
|
||||
It is a field the user types into; treating it as a socket demanded a source that no
|
||||
node produces and blocked generation."""
|
||||
oi = dict(OI)
|
||||
oi["LTXVEmptyLatentAudio"] = {
|
||||
"input": {"required": {
|
||||
"frames_number": ["INT", {"default": 97}],
|
||||
"frame_rate": ["FLOAT,INT", {"widgetType": "INT", "default": 25, "min": 1}],
|
||||
"batch_size": ["INT", {"default": 1}],
|
||||
"audio_vae": ["VAE"],
|
||||
}},
|
||||
"output": ["LATENT"], "output_name": ["Latent"],
|
||||
"display_name": "LTXV Empty Latent Audio",
|
||||
}
|
||||
desc = nodes.describe_node(oi, "LTXVEmptyLatentAudio")
|
||||
widgets = {w["name"]: w for w in desc["inputs"]}
|
||||
assert widgets["frame_rate"]["kind"] == "int" # renders as the widget it is
|
||||
assert widgets["frame_rate"]["default"] == 25
|
||||
ci = {c["name"]: c["type"] for c in desc["connection_inputs"]}
|
||||
assert ci == {"audio_vae": "VAE"} # frame_rate is NOT a socket
|
||||
# ...and it still qualifies for the audio_encoder role (union widget isn't an input type)
|
||||
assert "LTXVEmptyLatentAudio" in [c["class"] for c in nodes.candidates(oi, "audio_encoder")]
|
||||
|
||||
|
||||
def test_multitype_socket_stays_a_socket_and_matches_any_member():
|
||||
"""A MultiType with no widget member ("IMAGE,MASK") is a real socket — it must still be
|
||||
offered as one, and fed by a producer of either member type."""
|
||||
oi = {"ImageResizeMatch": {"input": {"required": {"image": ["IMAGE"],
|
||||
"match": ["IMAGE,MASK"]}},
|
||||
"output": ["IMAGE"], "display_name": "Resize to Match"}}
|
||||
ci = {c["name"]: (c["type"], c["required"]) for c in nodes.describe_node(oi, "ImageResizeMatch")["connection_inputs"]}
|
||||
assert ci["match"] == ("IMAGE,MASK", True)
|
||||
assert nodes.type_accepts("IMAGE,MASK", "MASK")
|
||||
assert nodes.type_accepts("IMAGE,MASK", "IMAGE")
|
||||
assert not nodes.type_accepts("IMAGE,MASK", "LATENT")
|
||||
assert nodes.widget_type_of("IMAGE,MASK", {}) is None
|
||||
assert nodes.widget_type_of("FLOAT,INT", {}) == "FLOAT" # no hint → first member wins
|
||||
|
||||
|
||||
def test_models_store_roundtrip(tmp_path, monkeypatch):
|
||||
from movie_editor.backend import config
|
||||
monkeypatch.setattr(config, "DATA_DIR", tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user