fix(models): expose Studio's positive_conditioning; ignore dead pass-throughs

A CONDITIONING output had no port to reach in guided mode. It wires explicitly
only — it replaces the typed prompt. An unwired pass-through no longer counts as
a source, which had made every CLIP consumer read as ambiguous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-20 19:46:31 +03:00
co-authored by Claude Opus 5
parent 65814fa590
commit 33aaa1b883
5 changed files with 112 additions and 3 deletions
+22 -1
View File
@@ -81,11 +81,20 @@ OPEN_PORTS: list[tuple[str, str, str, bool]] = [ # (core_id, input, type, requ
("studio", "clip", "CLIP", True),
("studio", "source_image", "IMAGE", False),
("studio", "latent", "LATENT", False),
("studio", "positive_conditioning", "CONDITIONING", False),
("sampler", "vae", "VAE", True),
("concat", "audio_latent", "LATENT", True),
("audiodec", "audio_vae", "VAE", True),
]
# Ports that may be wired but must never AUTO-wire. A pre-encoded conditioning replaces
# the whole prompt path — no shortcuts, no $variables, no per-scene split — so it has to be
# something the user asked for. A lone CLIPTextEncode someone added for another purpose
# would otherwise capture the prompt silently.
EXPLICIT_ONLY_PORTS: frozenset[tuple[str, str]] = frozenset({
("studio", "positive_conditioning"),
})
# A required port another input can stand in for: {(core_id, input): (alternative inputs…)}.
# Studio encodes the prompt through `clip`, but a pre-encoded CONDITIONING wired into
# positive_conditioning replaces that path entirely (_v2_conditioning_source takes whichever
@@ -1018,8 +1027,18 @@ def _producers(graph, slots, slot_node_id, slot_def, object_info):
out.setdefault(t, []).append((cid, oidx))
for s in slots:
nd = slot_def[s["id"]]
node_id = slot_node_id[s["id"]]
# A pass-through output is only real once its own input is fed: the LoRA loader
# hands CLIP straight back, so an unwired one emits nothing while still making
# every other CLIP consumer look ambiguous ("2 possible sources").
dead = set()
for ci in connection_inputs(nd or {}):
if not isinstance((graph.get(node_id) or {}).get("inputs", {}).get(ci["name"]), list):
dead.add(ci["type"])
for i, o in enumerate(node_outputs(nd or {})):
out.setdefault(o["type"], []).append((slot_node_id[s["id"]], i))
if o["type"] in dead:
continue
out.setdefault(o["type"], []).append((node_id, i))
return out
@@ -1165,6 +1184,8 @@ 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)
if (node_id, inp) in EXPLICIT_ONLY_PORTS:
continue # wirable, never automatic — see EXPLICIT_ONLY_PORTS
alt_wired = next((alt for alt in PORT_ALTERNATIVES.get((node_id, inp), ())
if isinstance(node["inputs"].get(alt), list)), None)
if alt_wired:
+10 -1
View File
@@ -35,6 +35,11 @@ TYPE_CHAIN_TERMINALS: dict[str, list[str]] = {
"CLIP": ["FunPackStudio.clip"],
"LATENT": ["FunPackStudio.latent"],
"IMAGE": ["FunPackStudio.source_image"],
# Pre-encoded conditioning. Studio's own port, not the sampler's positive: the sampler's
# is a core-internal link, and feeding it directly would cut Studio out of its own
# pipeline. Never auto-wired (builder.EXPLICIT_ONLY_PORTS) because it replaces the
# typed prompt wholesale.
"CONDITIONING": ["FunPackStudio.positive_conditioning"],
}
# Core-internal ports: shown in the full port list but not user-wirable in guided mode
@@ -64,6 +69,7 @@ PORT_LABELS: dict[str, str] = {
"FunPackStudio.clip": "Studio · clip",
"FunPackStudio.latent": "Studio · latent (forwards to Concat AV · video_latent)",
"FunPackStudio.source_image": "Studio · source_image (Img2Video anchor)",
"FunPackStudio.positive_conditioning": "Studio · positive_conditioning (replaces the typed prompt)",
"FunPackLTXAVSceneChainSampler.vae": "Chain Sampler · vae",
"LTXVConcatAVLatent.audio_latent": "Concat AV Latent · audio_latent",
"LTXVAudioVAEDecode.audio_vae": "Audio VAE Decode · audio_vae",
@@ -76,6 +82,7 @@ OPEN_CORE_INPUTS: frozenset[tuple[str, str]] = frozenset({
("studio", "clip"),
("studio", "source_image"),
("studio", "latent"),
("studio", "positive_conditioning"),
("sampler", "vae"),
("concat", "audio_latent"),
("audiodec", "audio_vae"),
@@ -86,6 +93,7 @@ PORT_TO_OPEN_CORE: dict[str, tuple[str, str]] = {
"FunPackStudio.clip": ("studio", "clip"),
"FunPackStudio.source_image": ("studio", "source_image"),
"FunPackStudio.latent": ("studio", "latent"),
"FunPackStudio.positive_conditioning": ("studio", "positive_conditioning"),
"FunPackLTXAVSceneChainSampler.vae": ("sampler", "vae"),
"LTXVConcatAVLatent.audio_latent": ("concat", "audio_latent"),
"LTXVAudioVAEDecode.audio_vae": ("audiodec", "audio_vae"),
@@ -130,7 +138,8 @@ FAMILY_WIRING: dict[str, dict] = {
("CONDITIONING", None, _H3_KEYFRAME_PORT)],
},
"type_chain_terminals": {"LATENT": [_H3_LATENT_PORT],
"CONDITIONING": [_H3_KEYFRAME_PORT]},
"CONDITIONING": [_H3_KEYFRAME_PORT,
"FunPackStudio.positive_conditioning"]},
"default_wires": {
"audio_vae": {"VAE": "port:VAEDecodeAudio.vae"},
"audio_encoder": {},
+19 -1
View File
@@ -640,7 +640,8 @@
const incoming = (config.slots || []).some((s2) =>
s2.id !== slot.id && Object.values(s2.wires || {}).some((tg) => wireTargets(tg).includes(`node:${slot.id}:${ci.name}`)));
if (incoming) return;
const prod = sources(slot, ci.type).filter((o) => o.value && o.value !== "timeline").length;
const prod = sources(slot, ci.type)
.filter((o) => o.value && o.value !== "timeline" && producerIsLive(o.value)).length;
if (prod === 1) return; // a single producer auto-wires
if (prod > 1)
issues.push({ level: "error", msg: `Input "${ci.name}" (${ci.type}): ${prod} possible sources — set its Input source.` });
@@ -1042,6 +1043,23 @@
// Available sources for a slot connection input of a given type.
// Source IDs: "" = auto, "out:<slotId>:<outName>", "core:<coreId>:<outIdx>",
// "timeline" (IMAGE only), "ref:<mediaId>" (media marked R in the bin).
// A pass-through output is only a real source once its own input is fed: the LoRA loader
// hands CLIP straight back, so an unwired one emits nothing while still making every
// other CLIP consumer read as ambiguous. Matches builder._producers, which is what
// actually decides whether generation is blocked.
function producerIsLive(value) {
const m = /^out:([^:]+):(.+)$/.exec(String(value));
if (!m) return true;
const slot = (config.slots || []).find((s) => s.id === m[1]);
if (!slot) return true;
const spec = specFor(slot);
if (!spec) return true;
const out = (spec.outputs || []).find((o) => o.name === m[2]);
if (!out) return true;
return !(spec.connection_inputs || []).some(
(ci) => ci.type === out.type && !inputIsFed(slot, ci.name));
}
function sources(slot, type) {
const out = [{ value: "", label: "(auto-wire)" }];
if (typeAccepts(type, "IMAGE")) out.push({ value: "timeline", label: "Timeline (scene image)" });
+42
View File
@@ -946,6 +946,9 @@ OI_DEFAULTS = dict(OI, **{
"frame_rate": ["FLOAT,INT", {"default": 25.0, "widgetType": "FLOAT"}],
"audio_vae": ["VAE", {}]}},
"output": ["LATENT"], "output_name": ["Latent"]},
"CLIPTextEncode": {
"input": {"required": {"text": ["STRING", {"default": ""}], "clip": ["CLIP", {}]}},
"output": ["CONDITIONING"], "output_name": ["CONDITIONING"]},
"FunPackLoraLoader": {
"input": {"required": {"model": ["MODEL", {}],
"lora_list": ["STRING", {"default": "[]",
@@ -1017,3 +1020,42 @@ def test_the_seeded_audio_latent_follows_the_project_not_its_own_widgets():
assert audio["inputs"]["frame_rate"] == ["fps", 0]
# and it takes the AUDIO vae, not whichever VAE auto-wire happened to reach first
assert audio["inputs"]["audio_vae"] == graph["audiodec"]["inputs"]["audio_vae"]
def test_a_conditioning_node_never_captures_the_prompt_by_itself():
"""positive_conditioning replaces the typed prompt wholesale — shortcuts, $variables and
the per-scene split all go with it — so it must be something the user wired on purpose."""
models = _seeded_models()
models["slots"].append({
"id": "enc", "role": "custom", "node_class": "CLIPTextEncode",
"inputs": {"text": "hello"}, "wires": {}, "input_sources": {}})
graph, report = builder.build(OI_DEFAULTS, models, PARAMS)
assert "positive_conditioning" not in graph["studio"]["inputs"]
assert not any("positive_conditioning" in m for m in report["blocking"])
def test_a_conditioning_wire_the_user_asked_for_is_honoured():
models = _seeded_models()
models["slots"].append({
"id": "enc", "role": "custom", "node_class": "CLIPTextEncode",
"inputs": {"text": "hello"},
"wires": {"CONDITIONING": ["port:FunPackStudio.positive_conditioning"]},
"input_sources": {}})
graph, report = builder.build(OI_DEFAULTS, models, PARAMS)
assert isinstance(graph["studio"]["inputs"]["positive_conditioning"], list)
assert report["blocking"] == []
def test_an_unwired_pass_through_is_not_counted_as_a_source():
"""The seeded LoRA loader hands CLIP straight back. With its clip input unwired it emits
nothing, and counting it made every other CLIP consumer read as ambiguous."""
models = _seeded_models()
models["slots"].append({
"id": "enc", "role": "custom", "node_class": "CLIPTextEncode",
"inputs": {"text": "hello"},
"wires": {"CONDITIONING": ["port:FunPackStudio.positive_conditioning"]},
"input_sources": {}})
graph, report = builder.build(OI_DEFAULTS, models, PARAMS)
by_class = {n["class_type"]: nid for nid, n in graph.items()}
assert graph[by_class["CLIPTextEncode"]]["inputs"]["clip"] == [by_class["FunPackCLIPLoader"], 0]
assert report["ambiguous"] == []
@@ -503,3 +503,22 @@ def test_every_open_core_input_is_reachable_from_some_role():
if port in pipeline_wiring._hidden_ports(family):
continue
assert port in offered, (family, port)
def test_pre_encoded_conditioning_has_a_port_in_both_families():
"""A CONDITIONING output could be added and never connected: Studio's own port was not
offered, and the sampler's positive is a core-internal link."""
for family in ("ltxav", "minimax_h3"):
assert "FunPackStudio.positive_conditioning" in \
pipeline_wiring._chain_terminals(family)["CONDITIONING"]
assert "FunPackStudio.positive_conditioning" in \
pipeline_wiring._type_fallback_ports(family)["CONDITIONING"]
def test_the_samplers_own_positive_stays_core_owned():
"""Studio produces the conditioning the sampler samples. Wiring straight into the
sampler would cut Studio out of its own pipeline."""
for family in ("ltxav", "minimax_h3"):
offered = {p for ports in pipeline_wiring._type_fallback_ports(family).values()
for p in ports}
assert "FunPackLTXAVSceneChainSampler.positive" not in offered