diff --git a/movie_editor/backend/pipeline_wiring.py b/movie_editor/backend/pipeline_wiring.py index 03240554..b97a31e6 100644 --- a/movie_editor/backend/pipeline_wiring.py +++ b/movie_editor/backend/pipeline_wiring.py @@ -7,6 +7,8 @@ legacy free rewiring behaviour. """ from __future__ import annotations +import json + from typing import Any, Optional # port id (NodeClass.input) allowed for each model role + output type. @@ -534,6 +536,155 @@ def seed_default_pipeline(models: dict, object_info: Optional[dict] = None) -> d return models +# Where a seeded loader's file lives, and which widget names on ANOTHER pipeline's node +# hold the same file. An imported workflow uses whatever loader its author picked, so the +# only reliable bridge between the two is the file name itself. +SEEDED_FILE_INPUTS: dict[str, tuple[str, tuple[str, ...]]] = { + "FunPackDiffusionModelLoader": ("model_name", ("model_name", "unet_name", "ckpt_name")), + "FunPackVAELoader": ("vae_name", ("vae_name",)), +} + + +def _combo_choices(node_def: Optional[dict], input_name: str) -> Optional[list]: + inp = (node_def or {}).get("input") or {} + for group in ("required", "optional"): + spec = (inp.get(group) or {}).get(input_name) + if isinstance(spec, (list, tuple)) and spec and isinstance(spec[0], list): + return spec[0] + return None + + +def is_workflow_import(models: Optional[dict]) -> bool: + """A pipeline built from someone's exported graph rather than from FunPack's loaders.""" + models = models if isinstance(models, dict) else {} + return bool(models.get("workflow_import") or models.get("disable_core")) + + +def _file_input_names(cls: str) -> tuple[str, ...]: + if cls == "FunPackCLIPLoader": + return ("clip_name",) # matched by prefix: clip_name, clip_name1, clip_name2… + return SEEDED_FILE_INPUTS.get(cls, (None, ()))[1] + + +def _slot_picks(slot: dict, cls: str) -> list[str]: + """The file names a slot has picked, in widget-name order.""" + names = _file_input_names(cls) + out = [] + for key in sorted((slot.get("inputs") or {}).keys()): + value = (slot.get("inputs") or {})[key] + if not isinstance(value, str) or not value: + continue + if key.startswith(names[0]) if cls == "FunPackCLIPLoader" else key in names: + out.append(value) + return out + + +def _donor_for(slot: dict, source_slots: list[dict]) -> Optional[dict]: + """The old slot that held the same file as this seeded loader. + + Role first — but an imported workflow records every node as `custom`, so the fallbacks + matter more than the happy path: a node wired to the same core port is the same link in + the pipeline, and failing that, a file input only one node in the whole graph has can + only be that one. Anything ambiguous is left for the user rather than guessed at. + """ + cls = slot.get("node_class") + role = slot.get("role") + candidates = [d for d in source_slots if _slot_picks(d, cls)] + if not candidates: + return None + by_role = [d for d in candidates if role and d.get("role") == role] + if len(by_role) == 1: + return by_role[0] + targets = {t for wires in (slot.get("wires") or {}).values() for t in (wires or [])} + if targets: + by_port = [d for d in candidates + if targets & {t for w in (d.get("wires") or {}).values() for t in (w or [])}] + if len(by_port) == 1: + return by_port[0] + return candidates[0] if len(candidates) == 1 else None + + +def carry_over_model_files(slots: list[dict], source: Optional[dict], + object_info: Optional[dict] = None) -> int: + """Move the FILES an old pipeline had picked onto freshly seeded loaders. + + The point of a new project inheriting anything at all is not having to find the same + files again. A name the installed node does not offer is skipped rather than written, + so a stale pick cannot make an empty loader look configured. + """ + source_slots = (source or {}).get("slots") or [] + carried = 0 + for slot in slots: + cls = slot.get("node_class") + if cls != "FunPackCLIPLoader" and cls not in SEEDED_FILE_INPUTS: + continue + donor = _donor_for(slot, source_slots) + if donor is None: + continue + picks = _slot_picks(donor, cls) + node_def = (object_info or {}).get(cls) + if cls == "FunPackCLIPLoader": + choices = _combo_choices(node_def, "clip_list") or [] + rows = [{"clip_name": n} for n in picks if not choices or n in choices] + if rows: + slot.setdefault("inputs", {})["clip_list"] = json.dumps(rows) + carried += len(rows) + continue + target = SEEDED_FILE_INPUTS[cls][0] + choices = _combo_choices(node_def, target) + pick = next((n for n in picks if not choices or n in choices), None) + if pick: + slot.setdefault("inputs", {})[target] = pick + carried += 1 + return carried + + +def is_seeded_pipeline(models: Optional[dict]) -> bool: + """True when the pipeline is still exactly what seeding produced — no node the user + added, so rebuilding it for another family throws away nothing of theirs.""" + slots = (models or {}).get("slots") or [] + seeded_ids = {f"fp_{role}" for role, _c, _l, _s in FUNPACK_DEFAULT_LOADERS} + for extras in FAMILY_DEFAULT_EXTRAS.values(): + seeded_ids |= {f"fp_{role}" for role, _c, _l, _s in extras} + return bool(slots) and all(s.get("id") in seeded_ids for s in slots) + + +def reseed_for_family(models: dict, object_info: Optional[dict] = None) -> bool: + """Rebuild an untouched seeded pipeline for the family it now says it is. + + Answering "MiniMax H3" and being handed LTX's nodes is the setup answering back with + something the user did not choose. Only ever runs on a pipeline nobody has edited. + """ + if not isinstance(object_info, dict) or not is_seeded_pipeline(models): + return False + slots = default_pipeline_slots(family_of(models), object_info) + if not slots: + return False + carry_over_model_files(slots, models, object_info) + models["slots"] = slots + return True + + +def new_project_models(glob: Optional[dict], object_info: Optional[dict] = None) -> dict: + """The pipeline a NEW project starts with. + + A configured pipeline IS the template — picking the same files in every project is the + thing the global default exists to prevent. An IMPORTED WORKFLOW is not: it is one + project's graph, and copying it hands every later project a pile of third-party loaders + to understand instead of FunPack's four. The files it picked come along; the graph does + not. + """ + glob = glob if isinstance(glob, dict) else {} + if not is_workflow_import(glob): + return dict(glob) + carried = {k: glob[k] for k in ("model_family", "full_control") if glob.get(k)} + carried["slots"] = [] + seed_default_pipeline(carried, object_info) + if carried.get("slots"): + carry_over_model_files(carried["slots"], glob, object_info) + return carried + + def wiring_rules_payload(family: str = DEFAULT_FAMILY) -> dict[str, Any]: """Static rules for the Models UI, for one model family.""" family = family if family in FAMILY_WIRING else DEFAULT_FAMILY diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index 029dd65d..2c6532bf 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -1294,6 +1294,7 @@ _setWireTarget(slot, o, "", t); // mirror node: targets onto the destination's input source }); Object.entries(draft.input_sources).forEach(([inp, v]) => { if (v) _setInputSource(slot, inp, v); }); + feedMatchingInputs(slot); reconcileOpenPortWiring(); await persist(); closeSetup(); @@ -2202,6 +2203,29 @@ }); } + // A role whose output more than one node needs, and the input name each of them uses. + // The audio VAE is the case that bites: it decodes the sound AND encodes the empty audio + // latent, so wiring it to one of the two leaves the pipeline unable to generate — and + // with two VAE loaders present neither input can auto-resolve by type, so nothing filled + // the gap and nothing said which node was still waiting. + const ROLE_EXTRA_INPUTS = { audio_vae: ["audio_vae"], video_vae: ["vae"] }; + + function feedMatchingInputs(slot) { + const names = ROLE_EXTRA_INPUTS[slot.role]; + if (!names) return; + const out = ((specFor(slot) || {}).outputs || [])[0]; + if (!out) return; + (config.slots || []).forEach((other) => { + if (other.id === slot.id) return; + ((specFor(other) || {}).connection_inputs || []).forEach((ci) => { + if (!names.includes(ci.name) || !typeAccepts(ci.type, out.type)) return; + const cur = (other.input_sources || {})[ci.name]; + if (cur && cur !== "auto") return; + _setInputSource(other, ci.name, `out:${slot.id}:${out.name}`); + }); + }); + } + function loaderSection(slots, v) { const sec = el("div", "node-group node-group-pinned"); const head = el("div", "node-group-head"); diff --git a/movie_editor/server.py b/movie_editor/server.py index 9b0d2684..70bbd803 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -1332,7 +1332,15 @@ if web is not None and PromptServer is not None: # projects, so copy it whole and let the per-key rules below decide the rest. glob = nodes.load_models() if isinstance(glob, dict) and glob: - p.models = glob + try: + oi = await bridge.object_info() + except Exception: + oi = None + # ...except when the global is an IMPORTED WORKFLOW. That is one project's + # graph, not a template: copying it starts every new project with a pile of + # third-party loaders to wire, which is precisely what FunPack's own loaders + # exist to remove. The files it picked are carried over; the graph is not. + p.models = pipeline_wiring.new_project_models(glob, oi) projects.save(p) return web.json_response(p.to_dict()) @@ -2593,6 +2601,17 @@ if web is not None and PromptServer is not None: before = len(body.get("slots") or []) pipeline_wiring.seed_default_pipeline(body, oi) seeded_now = len(body.get("slots") or []) > before + # The family step can land here after seeding, and a pipeline nobody has touched + # must follow the answer: being handed LTX's nodes after choosing H3 is the setup + # contradicting the user. Only an untouched seed is rebuilt. + elif body.get("model_family") != (getattr(p, "models", None) or {}).get("model_family") \ + and pipeline_wiring.is_seeded_pipeline(body): + try: + oi = await bridge.object_info() + except Exception: + oi = None + if pipeline_wiring.reseed_for_family(body, oi): + seeded_now = True p.models = body projects.save(p) # Keep the global default in sync (it seeds new projects) — but never let a save that diff --git a/movie_editor/tests/test_pipeline_wiring.py b/movie_editor/tests/test_pipeline_wiring.py index 79ff6229..2b77898b 100644 --- a/movie_editor/tests/test_pipeline_wiring.py +++ b/movie_editor/tests/test_pipeline_wiring.py @@ -1,4 +1,5 @@ """Tests for guided vs full-control pipeline wiring rules.""" +import json import sys from pathlib import Path @@ -357,3 +358,112 @@ def test_the_default_pipeline_is_complete_not_just_the_loaders(): def test_plumbing_for_another_family_is_not_seeded(): slots = pipeline_wiring.default_pipeline_slots("minimax_h3", OI_WITH_PLUMBING) assert "audio_encoder" not in {s["role"] for s in slots} + + +# ── what a NEW project starts from ──────────────────────────────────────────── + +IMPORTED_GLOBAL = { + "model_family": "ltxav", + "workflow_import": {"name": "PinkCherry.json"}, + "disable_core": False, + "slots": [ + {"id": "w1", "role": "custom", "node_class": "DiffusionModelLoaderKJ", + "inputs": {"model_name": "LTX25dist.safetensors", "quantization": "fp8"}, + "wires": {"MODEL": ["node:w2:model"]}}, + {"id": "w2", "role": "custom", "node_class": "LTX2LoraLoaderAdvanced", + "inputs": {"lora_name": "mistic.safetensors"}, "wires": {"MODEL": ["node:w3:model"]}}, + {"id": "w4", "role": "custom", "node_class": "CLIPLoader", + "inputs": {"clip_name": "gemma4-12b.safetensors"}, + "wires": {"CLIP": ["port:FunPackStudio.clip"]}}, + {"id": "w5", "role": "custom", "node_class": "VAELoader", + "inputs": {"vae_name": "ltx-2.5-video-vae-bf16.safetensors"}, + "wires": {"VAE": ["port:FunPackLTXAVSceneChainSampler.vae"]}}, + {"id": "w6", "role": "custom", "node_class": "VAELoader", + "inputs": {"vae_name": "ltx-2.5-audio-vae-bf16.safetensors"}, + "wires": {"VAE": ["port:LTXVAudioVAEDecode.audio_vae"]}}, + ], +} + +OI_FILES = dict(OI_LOADERS, **{ + "FunPackDiffusionModelLoader": {"input": {"required": { + "model_name": [["LTX25dist.safetensors", "other.safetensors"]], + "weight_dtype": [["default"], {"default": "default"}]}}, "output": ["MODEL"]}, + "FunPackCLIPLoader": {"input": {"required": { + "clip_list": ["STRING", {"default": "[]", + "funpack_list": {}, }], + "type": [["ltxv"], {"default": "ltxv"}]}}, "output": ["CLIP"]}, + "FunPackVAELoader": {"input": {"required": { + "vae_name": [["ltx-2.5-video-vae-bf16.safetensors", + "ltx-2.5-audio-vae-bf16.safetensors"]], + "dtype": [["default"], {"default": "default"}]}}, "output": ["VAE"]}, +}) + + +def test_an_imported_workflow_is_not_the_template_for_every_later_project(): + """It is ONE project's graph. Copying it starts a fresh setup with a pile of + third-party loaders to understand — the thing FunPack's own loaders remove.""" + fresh = pipeline_wiring.new_project_models(IMPORTED_GLOBAL, OI_FILES) + assert "workflow_import" not in fresh + assert [s["node_class"] for s in fresh["slots"]] == [ + "FunPackDiffusionModelLoader", "FunPackLoraLoader", "FunPackCLIPLoader", + "FunPackVAELoader", "FunPackVAELoader"] + assert fresh["model_family"] == "ltxav" # what IS reusable comes along + + +def test_the_files_the_old_pipeline_had_picked_come_along(): + """Not having to find the same files again is the only reason to inherit anything.""" + slots = {s["id"]: s for s in pipeline_wiring.new_project_models(IMPORTED_GLOBAL, OI_FILES)["slots"]} + assert slots["fp_unet"]["inputs"]["model_name"] == "LTX25dist.safetensors" + assert slots["fp_video_vae"]["inputs"]["vae_name"] == "ltx-2.5-video-vae-bf16.safetensors" + assert slots["fp_audio_vae"]["inputs"]["vae_name"] == "ltx-2.5-audio-vae-bf16.safetensors" + assert json.loads(slots["fp_clip"]["inputs"]["clip_list"]) == [ + {"clip_name": "gemma4-12b.safetensors"}] + + +def test_a_file_this_comfyui_does_not_have_is_not_carried_over(): + """Writing a stale pick would make an unconfigured loader look configured.""" + source = {"slots": [{"id": "w1", "role": "unet", "node_class": "UNETLoader", + "inputs": {"unet_name": "gone.safetensors"}}]} + slots = pipeline_wiring.default_pipeline_slots("ltxav", OI_FILES) + assert pipeline_wiring.carry_over_model_files(slots, source, OI_FILES) == 0 + assert "model_name" not in {s["id"]: s for s in slots}["fp_unet"]["inputs"] + + +def test_an_ambiguous_donor_is_left_for_the_user_to_pick(): + """Two VAEs wired to nothing recognisable: guessing which is the audio one is worse + than an empty picker that says it is empty.""" + source = {"slots": [ + {"id": "a", "role": "custom", "node_class": "VAELoader", + "inputs": {"vae_name": "ltx-2.5-video-vae-bf16.safetensors"}}, + {"id": "b", "role": "custom", "node_class": "VAELoader", + "inputs": {"vae_name": "ltx-2.5-audio-vae-bf16.safetensors"}}]} + slots = pipeline_wiring.default_pipeline_slots("ltxav", OI_FILES) + pipeline_wiring.carry_over_model_files(slots, source, OI_FILES) + for slot in slots: + if slot["node_class"] == "FunPackVAELoader": + assert "vae_name" not in slot["inputs"] + + +def test_a_configured_funpack_pipeline_is_still_the_template(): + glob = {"model_family": "ltxav", + "slots": pipeline_wiring.default_pipeline_slots("ltxav", OI_FILES)} + assert pipeline_wiring.new_project_models(glob, OI_FILES) == glob + + +def test_an_untouched_pipeline_follows_the_family_the_user_picked(): + """Answering "MiniMax H3" and being handed LTX's nodes is the setup contradicting + the user.""" + models = pipeline_wiring.new_project_models(IMPORTED_GLOBAL, OI_FILES) + models["model_family"] = "minimax_h3" + assert pipeline_wiring.reseed_for_family(models, OI_FILES) is True + audio = {s["id"]: s for s in models["slots"]}["fp_audio_vae"] + assert audio["wires"] == {"VAE": ["port:VAEDecodeAudio.vae"]} + assert audio["inputs"]["vae_name"] == "ltx-2.5-audio-vae-bf16.safetensors" # kept + + +def test_a_pipeline_the_user_edited_is_never_rebuilt_under_them(): + models = pipeline_wiring.new_project_models(IMPORTED_GLOBAL, OI_FILES) + models["slots"].append({"id": "mine", "role": "custom", "node_class": "VAELoader"}) + models["model_family"] = "minimax_h3" + assert pipeline_wiring.reseed_for_family(models, OI_FILES) is False + assert any(s["id"] == "mine" for s in models["slots"])