diff --git a/movie_editor/backend/pipeline_wiring.py b/movie_editor/backend/pipeline_wiring.py index 0ff24948..49750b33 100644 --- a/movie_editor/backend/pipeline_wiring.py +++ b/movie_editor/backend/pipeline_wiring.py @@ -680,6 +680,32 @@ def reseed_for_family(models: dict, object_info: Optional[dict] = None) -> bool: return True +def global_template_update(current: Optional[dict], saved: dict) -> Optional[dict]: + """What a project's save should leave in the global default that seeds NEW projects. + + Only FunPack's own seeded loaders travel: those are the file picks anyone's next + project wants, and the reason the global exists. An imported workflow or a hand-wired + graph is that ONE project's business — mirroring it whole is how every later project + came to adopt it, loaders and all. The family is reusable either way, so it is recorded + on its own. + + Returns the global to write, or None when the save must not touch it. + """ + current = current if isinstance(current, dict) else {} + saved = saved if isinstance(saved, dict) else {} + if is_seeded_pipeline(saved) and not is_workflow_import(saved): + out = dict(saved) + # A save that simply omits the family must not erase the one already recorded: + # a caller changing it sends it, one rearranging loaders does not. + if not out.get("model_family") and current.get("model_family"): + out["model_family"] = current["model_family"] + return out + family = saved.get("model_family") + if family and current.get("model_family") != family: + return {**current, "model_family": family} + return None + + def new_project_models(glob: Optional[dict], object_info: Optional[dict] = None) -> dict: """The pipeline a NEW project starts with. diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index 91353779..bdc8ab4c 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -1679,7 +1679,14 @@ } ensureLinks().push(link); applyLinkValue(link, val); - linkMode = false; linkSel = []; await persist(); setView("links"); + linkMode = false; linkSel = []; + // Members are picked ON node pages, where saving is deferred until the node's Save + // button. Going through that buffer meant "Save link" only marked the page dirty, and + // the jump to the links view then offered to DISCARD the link it had just made — so a + // link could never be created at all. Pressing Save link is the explicit save. + if (deferSave) { await persistNow(); deferDirty = false; } + else await persist(); + setView("links"); } // Persistent bar while picking link members — stays visible as the user moves diff --git a/movie_editor/server.py b/movie_editor/server.py index 70bbd803..dab78d0a 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -2614,22 +2614,13 @@ if web is not None and PromptServer is not None: 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 - # simply omits `model_family` erase the one already recorded there. A caller that - # means to change the family sends it; one that is only rearranging loaders does not, - # and silently clearing it is how every later project ends up with no family at all. - # - # A SEED is not a user edit, and must not reach the global at all: opening a new - # project would otherwise replace the loaders someone configured — the very thing - # the global exists to hand on — with four empty ones. + # A SEED is not a user edit and must not reach the global default at all: opening a + # new project would otherwise replace the loaders someone configured with empty ones. if seeded_now: return web.json_response(body) - glob = dict(body) - if not glob.get("model_family"): - inherited = nodes.load_models().get("model_family") - if inherited: - glob["model_family"] = inherited - nodes.save_models(glob) + glob = pipeline_wiring.global_template_update(nodes.load_models(), body) + if glob is not None: + nodes.save_models(glob) return web.json_response(body) # --- UI: legacy entry points --- diff --git a/movie_editor/tests/test_pipeline_wiring.py b/movie_editor/tests/test_pipeline_wiring.py index fd9628fa..164cc287 100644 --- a/movie_editor/tests/test_pipeline_wiring.py +++ b/movie_editor/tests/test_pipeline_wiring.py @@ -522,3 +522,38 @@ def test_the_samplers_own_positive_stays_core_owned(): offered = {p for ports in pipeline_wiring._type_fallback_ports(family).values() for p in ports} assert "FunPackLTXAVSceneChainSampler.positive" not in offered + + +# ── what a project's save leaves in the global default ──────────────────────── + +def test_an_imported_workflow_never_becomes_the_global_template(): + """Mirroring one project's graph is how every later project came to adopt it.""" + current = {"model_family": "ltxav", "slots": []} + saved = dict(IMPORTED_GLOBAL) + assert pipeline_wiring.global_template_update(current, saved) is None + + +def test_a_hand_wired_graph_stays_that_projects_business(): + saved = {"model_family": "ltxav", + "slots": [{"id": "mine", "role": "custom", "node_class": "VAELoader"}]} + assert pipeline_wiring.global_template_update({"model_family": "ltxav"}, saved) is None + + +def test_seeded_loaders_do_travel_because_that_is_what_the_global_is_for(): + """Picking the same model files in every project is the thing it prevents.""" + saved = {"model_family": "ltxav", + "slots": pipeline_wiring.default_pipeline_slots("ltxav", OI_FILES)} + assert pipeline_wiring.global_template_update({}, saved) == saved + + +def test_the_family_is_recorded_even_when_the_graph_is_not(): + saved = dict(IMPORTED_GLOBAL, model_family="minimax_h3") + out = pipeline_wiring.global_template_update({"model_family": "ltxav", "slots": []}, saved) + assert out["model_family"] == "minimax_h3" + assert out["slots"] == [] # the old global's own slots, not the import's + + +def test_a_save_that_omits_the_family_does_not_erase_it(): + saved = {"slots": pipeline_wiring.default_pipeline_slots("ltxav", OI_FILES)} + out = pipeline_wiring.global_template_update({"model_family": "minimax_h3"}, saved) + assert out["model_family"] == "minimax_h3"