fix: manifest refreshed on adopt, deferred adopt after a failed load, split clips count once for length, non-slot project.models keys kept, bounded waits

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-02 23:51:11 +03:00
co-authored by Claude Sonnet 5.5
parent 5673f7513b
commit dfb0cc5a8d
3 changed files with 32 additions and 7 deletions
+12 -3
View File
@@ -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 */ }
+12
View File
@@ -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");
});
+8 -4
View File
@@ -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);