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:
co-authored by
Claude Opus 5
parent
eb78f35a1e
commit
e93ad88ec8
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user