diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index 19c1b130..12253fe4 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -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: diff --git a/movie_editor/backend/pipeline_wiring.py b/movie_editor/backend/pipeline_wiring.py index 39f28429..0ff24948 100644 --- a/movie_editor/backend/pipeline_wiring.py +++ b/movie_editor/backend/pipeline_wiring.py @@ -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": {}, diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index a3800740..91353779 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -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::", "core::", // "timeline" (IMAGE only), "ref:" (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)" }); diff --git a/movie_editor/tests/test_builder.py b/movie_editor/tests/test_builder.py index b65f311a..941c427c 100644 --- a/movie_editor/tests/test_builder.py +++ b/movie_editor/tests/test_builder.py @@ -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"] == [] diff --git a/movie_editor/tests/test_pipeline_wiring.py b/movie_editor/tests/test_pipeline_wiring.py index 661f1233..fd9628fa 100644 --- a/movie_editor/tests/test_pipeline_wiring.py +++ b/movie_editor/tests/test_pipeline_wiring.py @@ -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