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:
co-authored by
Claude Opus 5
parent
65814fa590
commit
33aaa1b883
@@ -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:
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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)" });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user