diff --git a/app/legacy/pipeline_state.js b/app/legacy/pipeline_state.js index dea16018..83d065f4 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 deferred = null; // a project's saved pipeline waiting for the first successful load let epoch = 0; // bumped whenever a project's pipeline replaces the live one let saving = false; let pending = false; @@ -127,6 +128,7 @@ } loading = false; loadPromise = null; + if (slots !== null && deferred) { const d = deferred; deferred = null; await adopt(d); } })(); return loadPromise; } @@ -293,7 +295,10 @@ // Not announced as a change: it IS the project's own copy. async function adopt(saved) { await ensureLoaded(); - if (slots === null || !Array.isArray(saved) || !saved.length) return; + // Not loaded (ComfyUI unreachable): remember what the project holds and put it in the moment a + // load succeeds, so the default is never what the next edit is built on. + if (slots === null) { if (Array.isArray(saved) && saved.length) deferred = saved; return; } + if (!Array.isArray(saved) || !saved.length) return; // 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" @@ -309,16 +314,20 @@ return out; }).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++; + const mine = ++epoch; pendingBody = null; pending = false; - while (saving) await new Promise((r) => setTimeout(r, 20)); + for (let waited = 0; saving && waited < 10000; waited += 20) await new Promise((r) => setTimeout(r, 20)); for (const extras of [true, false]) { try { const res = await API.editPipeline({ slots: lay(extras) }); + if (mine !== epoch) return; // a later project was opened meanwhile if (res && res.slots && !(res.refused || []).length) { slots = res.slots; incomplete = res.incomplete || []; refused = []; queueable = !!res.queueable; saveNotes = res.notes || []; + // The modules offered depend on the model this pipeline names, which may differ from the + // previous project's: refresh, or the next settings edit drops this project's modules. + try { await refreshManifest(); } catch (_) { /* the next save retries it */ } return; } } catch (_) { /* this layering was refused; try the next */ } diff --git a/app/legacy/pipeline_state.test.js b/app/legacy/pipeline_state.test.js index 7376f881..2e684f1c 100644 --- a/app/legacy/pipeline_state.test.js +++ b/app/legacy/pipeline_state.test.js @@ -91,3 +91,15 @@ test("nothing loaded (ComfyUI unreachable) leaves slots null, never an empty pip await P2.adopt([{ id: "model", node: "Loader", inputs: {} }]); assert.strictEqual(P2.slots(), null); }); + +test("a project's pipeline waits out an unreachable server and goes in on the first successful load", async () => { + const PS = load([]); + const good = global.window.MovieEditorAPI.pipeline; + let up = false; + global.window.MovieEditorAPI.pipeline = async () => { if (!up) throw new Error("down"); return good(); }; + await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "h3.safetensors" } }]); + assert.strictEqual(PS.slots(), null); + up = true; + await PS.ensureLoaded(); + assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "h3.safetensors"); +}); diff --git a/app/legacy/store.js b/app/legacy/store.js index 5fb5453b..542ee0a6 100644 --- a/app/legacy/store.js +++ b/app/legacy/store.js @@ -3741,7 +3741,8 @@ if (input === "length") { // The PROJECT's length per scene, never the scene's own: a scene cropped on the timeline // regenerates at the project length (the crop was a timeline decision; a regenerate is new). - const n = (targetSceneIds || []).filter((id) => scene(id)).length || 1; + // Story scenes, not scene rows: a split clip is one story scene in two rows. + const n = new Set((targetSceneIds || []).map((id) => scene(id)).filter(Boolean).map((sc) => genUnitId(sc))).size || 1; return n * p.num_frames_per_scene; } return undefined; @@ -4705,11 +4706,14 @@ const saved = (state.project && state.project.models && state.project.models.slots) || []; const p = (async () => { try { - await window.PipelineState.adopt(saved); + // Bounded: a hung request must not hold every later project save behind it. + await Promise.race([window.PipelineState.adopt(saved), new Promise((r) => setTimeout(r, 20000))]); 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 }; + // Everything else the project keeps beside the slots (model family...) stays as it was. + state.models = { ...((state.project && state.project.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 }; } @@ -5014,7 +5018,7 @@ // Every landed pipeline edit is the project's copy now; one writer (PipelineState), no snapshot of its own. window.PipelineState.subscribe((slots) => { if (!state.project) return; - state.models = { slots: JSON.parse(JSON.stringify(slots || [])) }; + state.models = { ...(state.models || {}), slots: JSON.parse(JSON.stringify(slots || [])) }; // Soon, and past a Settings window's hold: a model pick must survive the reload that follows it. _localDirty = true; clearTimeout(_pipelineSaveTimer);