fix(models): a new project starts as FunPack's loaders, not as the last import

An imported workflow was copied into every new project, so a fresh wizard setup
handed the user third-party loaders to wire. It now seeds FunPack's own and
carries the picked files across; an audio VAE feeds the audio latent too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-20 19:25:45 +03:00
co-authored by Claude Opus 5
parent eb78f35a1e
commit e93ad88ec8
4 changed files with 305 additions and 1 deletions
+151
View File
@@ -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
+24
View File
@@ -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");
+20 -1
View File
@@ -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
+110
View File
@@ -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"])