diff --git a/app/legacy/pipeline_state.js b/app/legacy/pipeline_state.js index 7bbc367f..dea16018 100644 --- a/app/legacy/pipeline_state.js +++ b/app/legacy/pipeline_state.js @@ -65,6 +65,7 @@ let queueable = false; let loading = false; let loadError = null; + let epoch = 0; // bumped whenever a project's pipeline replaces the live one let saving = false; let pending = false; let pendingBody = null; @@ -174,8 +175,12 @@ pending = false; const toSend = { slots, ...pendingBody }; pendingBody = null; + const mine = epoch; try { const res = await API.editPipeline(toSend); + // A different pipeline was put in while this was in flight (another project opened): + // this answer is about the old one and must not overwrite the new. + if (mine !== epoch) continue; if (res && res.slots) slots = res.slots; incomplete = (res && res.incomplete) || []; refused = (res && res.refused) || []; @@ -289,20 +294,37 @@ async function adopt(saved) { await ensureLoaded(); if (slots === null || !Array.isArray(saved) || !saved.length) return; - const byId = new Map(saved.filter((s) => s && typeof s.id === "string").map((s) => [s.id, s])); + // A saved slot is only what the server would accept: a project file outlives the code that + // wrote it (v4 files name the node differently), and one bad slot refuses every later edit. + const sound = (s) => s && typeof s.id === "string" && typeof s.node === "string" + && s.inputs && typeof s.inputs === "object" && !Array.isArray(s.inputs); + const byId = new Map(saved.filter(sound).map((s) => [s.id, s])); const have = new Set(offered.map((s) => s.id)); - const merged = offered.map((def) => { + const lay = (extras) => offered.map((def) => { const mine = byId.get(def.id); - if (!mine || mine.node !== def.node) return def; - const out = { ...def, inputs: { ...(def.inputs || {}), ...(mine.inputs || {}) } }; - if (mine.group) out.group = mine.group; - if (mine.bypassed !== undefined) out.bypassed = mine.bypassed; + if (!mine || mine.node !== def.node) return JSON.parse(JSON.stringify(def)); + const out = { ...def, inputs: { ...(def.inputs || {}), ...mine.inputs } }; + if (typeof mine.group === "string" && mine.group) out.group = mine.group; + if (typeof mine.bypassed === "boolean") out.bypassed = mine.bypassed; return out; - }).concat(saved.filter((s) => s && typeof s.id === "string" && !have.has(s.id))); - slots = merged; - const quiet = new Set(listeners); - listeners.clear(); - try { await save({}); } finally { quiet.forEach((fn) => listeners.add(fn)); } + }).concat(extras ? saved.filter((s) => sound(s) && !have.has(s.id)) : []); + // Replaces whatever pipeline was live: nothing queued for the old one may land on the new. + epoch++; + pendingBody = null; pending = false; + while (saving) await new Promise((r) => setTimeout(r, 20)); + for (const extras of [true, false]) { + try { + const res = await API.editPipeline({ slots: lay(extras) }); + if (res && res.slots && !(res.refused || []).length) { + slots = res.slots; + incomplete = res.incomplete || []; refused = []; queueable = !!res.queueable; + saveNotes = res.notes || []; + return; + } + } catch (_) { /* this layering was refused; try the next */ } + } + slots = JSON.parse(JSON.stringify(offered)); // nothing saved was usable: the default, said + saveNotes = ["This project's saved pipeline could not be loaded, so the default is in use."]; } window.PipelineState = { diff --git a/app/legacy/pipeline_state.test.js b/app/legacy/pipeline_state.test.js index c7a26157..7376f881 100644 --- a/app/legacy/pipeline_state.test.js +++ b/app/legacy/pipeline_state.test.js @@ -2,7 +2,7 @@ const test = require("node:test"); const assert = require("node:assert"); const fs = require("node:fs"); -function load(posts) { +function load(posts, opts = {}) { const defaults = [ { id: "model", node: "Loader", group: "Loaders", inputs: { file: "", dtype: "bf16" } }, { id: "gen", node: "Gen", group: "Sampling", inputs: { steps: 8, model: ["model", 0] } }, @@ -10,7 +10,12 @@ function load(posts) { global.window = { MovieEditorAPI: { pipeline: async () => ({ slots: JSON.parse(JSON.stringify(defaults)), incomplete: [], refused: [], queueable: true }), - editPipeline: async (body) => { posts.push(body); return { slots: body.slots, incomplete: [], refused: [], queueable: true }; }, + editPipeline: async (body) => { + posts.push(body); + if (opts.delay) await new Promise((r) => setTimeout(r, opts.delay)); + const bad = (body.slots || []).findIndex((s) => typeof s.node !== "string"); + if (bad >= 0) { const e = new Error(`slot ${bad} has no node`); throw e; } for (const [id, edits] of Object.entries(body.inputs || {})) { const sl = body.slots.find((x) => x.id === id); if (sl) sl.inputs = { ...sl.inputs, ...edits }; } + return { slots: body.slots, incomplete: [], refused: [], queueable: true }; }, modules: async () => ({ modules: [] }), probeFamily: async () => ({}), }, @@ -53,3 +58,36 @@ test("nothing saved leaves the session's pipeline as it is", async () => { await PS.adopt([]); assert.strictEqual(JSON.stringify(PS.slots()), before); }); + +test("a v4-shaped or hostile saved slot cannot brick the pipeline", async () => { + const PS = load([]); + await PS.adopt([ + { id: "model", node: "Loader", inputs: { file: "h3.safetensors" } }, + { id: "abc123", node_class: "OldV4Node", inputs: {} }, + { id: "bad", node: "X", inputs: [1, 2] }, + ]); + assert.deepStrictEqual(PS.slots().map((s) => s.id), ["model", "gen"]); + assert.strictEqual(PS.slots()[0].inputs.file, "h3.safetensors"); + await PS.save({ inputs: { model: { file: "new" } } }); + assert.strictEqual(PS.slots()[0].inputs.file, "new"); +}); + +test("an answer about the previous project's pipeline does not overwrite the one just opened", async () => { + const PS = load([], { delay: 30 }); + await PS.ensureLoaded(); + const inflight = PS.save({ inputs: { model: { file: "A-file" } } }); + await new Promise((r) => setTimeout(r, 5)); + await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "B-file" } }]); + await inflight; + assert.strictEqual(PS.slots()[0].inputs.file, "B-file"); +}); + +test("nothing loaded (ComfyUI unreachable) leaves slots null, never an empty pipeline", async () => { + const PS = load([]); + global.window.MovieEditorAPI.pipeline = async () => { throw new Error("down"); }; + delete require.cache[require.resolve("./pipeline_state.js")]; + require("./pipeline_state.js"); + const P2 = global.window.PipelineState; + await P2.adopt([{ id: "model", node: "Loader", inputs: {} }]); + assert.strictEqual(P2.slots(), null); +}); diff --git a/app/legacy/store.js b/app/legacy/store.js index 83d06869..5fb5453b 100644 --- a/app/legacy/store.js +++ b/app/legacy/store.js @@ -4699,12 +4699,19 @@ // copy of it (project.models) so a model pick and every node value travel with the project to // a new rental. Opening a project puts ITS pipeline over the session's; a project with none // yet inherits the session's. + let _modelsRetries = 0; + let _pipelineSaveTimer = null; async function loadModels() { const saved = (state.project && state.project.models && state.project.models.slots) || []; const p = (async () => { try { await window.PipelineState.adopt(saved); - state.models = { slots: JSON.parse(JSON.stringify(window.PipelineState.slots() || [])) }; + const live = window.PipelineState.slots(); + // Not loaded (ComfyUI unreachable): the project's own copy stays exactly as it was -- an + // empty live pipeline must never be mistaken for "no pipeline" and saved over it. Tried again. + state.models = { slots: live ? JSON.parse(JSON.stringify(live)) : saved }; + if (!live && _modelsRetries++ < 3) setTimeout(loadModels, 5000); + else if (live) _modelsRetries = 0; } catch (_) { state.models = { slots: saved }; } })(); _modelsLoad = p; @@ -5008,7 +5015,10 @@ window.PipelineState.subscribe((slots) => { if (!state.project) return; state.models = { slots: JSON.parse(JSON.stringify(slots || [])) }; - scheduleSaveSilent(); + // Soon, and past a Settings window's hold: a model pick must survive the reload that follows it. + _localDirty = true; + clearTimeout(_pipelineSaveTimer); + _pipelineSaveTimer = setTimeout(() => { _pipelineSaveTimer = null; flushSave(); }, 800); }); await refreshProjectList(); // Re-attach to a generation already running in ComfyUI (UI was reloaded mid-run) is