Files
olivv-cs-ComfyUI-FunPack/app/ui/shell/pipeline_state.js
T

706 lines
41 KiB
JavaScript

// The pipeline's slots, held ONCE per page session and shared by every panel
// that edits them (Engine Settings' module-values, Models & Pipeline's
// node-input edits). Two independent copies of "the current slots", each
// re-fetched on its own panel's mount, is how one panel's save silently
// reverts the other's: GET /api/pipeline is stateless (core/routes.py builds
// the DEFAULT graph fresh on every call, remembering nothing), so a panel
// that fetches on reopen throws away whatever ANY panel already placed --
// its own edits included, per the bug this file was extracted to fix once
// for good rather than have each new panel rediscover it.
// Every request this state makes gives up after a while: one that never answers (a stalled tunnel, a Mac that slept)
// would otherwise hold the pipeline's lock, and every edit behind it, until a reload. A Proxy, so an API object
// changed after creation (tests do) is still the one called.
const ANSWER_MS = 30000;
const bounded = (api, ms) => new Proxy(api, {
get(target, key) {
const f = target[key];
if (typeof f !== "function") return f;
return (...args) => {
let timer;
const late = new Promise((_, no) => { timer = setTimeout(() => no(new Error("ComfyUI did not answer in time")), ms); });
return Promise.race([Promise.resolve(f.apply(target, args)), late]).finally(() => clearTimeout(timer));
};
},
});
export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
const API = bounded(rawApi, answerMs);
// Loader nodes core knows how to point /api/probe at, and which of their
// inputs names the file. Mirrors the same map api.js's probeFamily() is
// meant to be fed from -- nothing else in this app currently resolves
// "the file the pipeline's model loader points at" from raw slots.
const MODEL_FILE_INPUT = {
FunPackCheckpointLoader: "ckpt_name",
FunPackDiffusionModelLoader: "model_name",
};
function currentModelFile(pipelineSlots) {
const slot = (pipelineSlots || []).find((s) => MODEL_FILE_INPUT[s.node] && s.inputs);
return slot ? slot.inputs[MODEL_FILE_INPUT[slot.node]] || null : null;
}
// Which traits the pipeline's chosen model has; null when that is not knowable (no file, or no model
// module recognised it, or the one that did names no traits): null means "do not filter", while a list,
// even an empty one, hides every module needing a trait it lacks. undefined: the probe failed, try again.
let modelNote = null; // why nothing is hidden for the chosen file, when no model module recognised it
async function probeModelTraits(pipelineSlots) {
const filename = currentModelFile(pipelineSlots);
modelNote = null;
if (!filename) return null;
try {
const data = await API.probeFamily(filename);
if (!data.detected && data.reason) modelNote = `${data.reason}, so every module is offered, including ones this model cannot use.`;
return data.detected && Array.isArray(data.traits) ? data.traits : null;
} catch (_) {
return undefined;
}
}
let modulesById = {};
let offered = null; // the server's default pipeline, as first fetched: what a project's saved copy is laid over
let slots = null; // null = never loaded this session; loaded exactly once
// The model file `modulesById` was last filtered for. A save() whose slots
// still name the same file has nothing new to learn -- re-probing on every
// unrelated edit (a prompt, a slider) would mean one extra round trip per
// keystroke-driven save for no reason.
let lastProbedFile; // undefined = never probed (a pipeline with no model file is null, and still needs its first probe)
async function refreshManifest() {
const file = currentModelFile(slots);
if (file === lastProbedFile) return false;
const traits = await probeModelTraits(slots);
const manifest = await API.modules(traits ? traits.join(",") : undefined);
modulesById = {};
(manifest.modules || []).forEach((m) => { modulesById[m.id] = m; });
controlState = manifest.control || controlState;
if (Array.isArray(manifest.sinks)) sinks = manifest.sinks;
if (traits !== undefined) lastProbedFile = file; // only once it worked: a failed probe is tried again
loadError = null; // the pipeline is in and its modules are known: nothing is failing now
return true;
}
let incomplete = [];
let refused = [];
let queueable = false;
let loading = false;
let loadError = null;
let unwired = {}; // default links the person cut: {slotId: [input, ...]}
let removed = new Set(); // default slots the person took out of this project's pipeline
let adoptGate = null; // a promise while a project's pipeline is being put in
let whole = false; // the pipeline replaced the default (a preset, an import, the wizard) rather than editing it
let adoptedOk = true; // false while the live pipeline is a stand-in for one that could not go in
let refusalLast = null; // the server's own reason for refusing the last project's pipeline
let unreachableLast = false; // the last project's pipeline failed because a request did not get through, not because it was refused
let adopting = 0; // adopts under way (waiting for the load, or putting a pipeline in)
let deferred = null; // a project's saved pipeline ([slots, removed, unwired, whole]) waiting for the first successful load
let epoch = 0; // bumped whenever a project's pipeline replaces the live one
let saving = false;
let shaping = 0; // node changes (swap, add, remove, preset, import) asked for and not yet answered: counted from the ask
let pending = false;
let pendingBody = null;
let saveNotes = [];
// What a settings_sink already holds, read back out of the live slots --
// place() (core/graph.py) writes a whole values blob as one opaque JSON
// string into the sink's input, never merging. No route tells the client
// WHICH node/input is the real sink (core does not name an implementation,
// by design), so this scans every string input for one that decodes to a
// plain object -- and only accepts a decoded key that names a module this
// session already knows is installed, so an unrelated node's incidentally
// JSON-shaped string can't inject a bogus entry that round-trips forever.
// Once the server has named its sinks, only those are read, and EVERY module's entry in them is kept: a module the
// current model hides still has its values there, and writing the tree back must not drop them.
let sinks = null; // [{node, input}] from the manifest; null until known (then the scan below is the fallback)
const isSink = (slot, key) => sinks.some((k) => k.node === slot.node && k.input === key);
function valuesAlreadyPlaced(from = slots) {
const known = new Set([...Object.keys(modulesById), "_off"]); // _off: the modules this project turned off
const merged = {};
(from || []).forEach((slot) => {
Object.entries(slot.inputs || {}).forEach(([key, v]) => {
if (typeof v !== "string" || (sinks && !isSink(slot, key))) return;
let parsed;
try { parsed = JSON.parse(v); } catch (_) { return; }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
Object.entries(parsed).forEach(([moduleId, own]) => {
if (!sinks && !known.has(moduleId)) return;
if (own && typeof own === "object") merged[moduleId] = { ...(merged[moduleId] || {}), ...own };
});
});
});
return merged;
}
// Two panels can mount close enough together that both call this before
// the first fetch settles (`slots` only becomes non-null at the end of a
// load, so a bare `if (slots !== null) return` doesn't cover the window
// while one is already in flight). Every caller awaits the SAME in-flight
// promise instead, so exactly one fetch ever goes out per session, however
// many panels ask.
let loadPromise = null;
async function ensureLoaded() {
if (slots !== null && !loadError) return; // session-wide: every consumer shares this one load
if (slots !== null) { // the pipeline came, its modules did not: ask for those again
if (!loadPromise) loadPromise = refreshManifest().then(() => { loadPromise = null; _changed("load"); }, () => { loadPromise = null; });
return loadPromise;
}
if (loadPromise) return loadPromise;
loading = true; loadError = null;
loadPromise = (async () => {
try {
// Sequential, not Promise.all: which modules are even compatible
// depends on which model the pipeline's loader slot names, so the
// manifest fetch has to know that before it can ask -- refreshManifest()
// reads `slots`, so it has to be set first.
const pipe = await API.pipeline();
slots = pipe.slots || [];
offered = JSON.parse(JSON.stringify(slots));
incomplete = pipe.incomplete || [];
refused = pipe.refused || [];
queueable = !!pipe.queueable;
await refreshManifest();
} catch (e) {
loadError = e && e.message ? e.message : String(e);
}
loading = false;
loadPromise = null;
// An adopt already under way carries the newest project and lays it itself; waiting on it from here would deadlock.
if (slots !== null && deferred) { const d = deferred; deferred = null; if (!adopting) await adopt(...d); }
})();
return loadPromise;
}
// `save()`'s body is one level deeper than a shallow spread can merge
// correctly: `inputs`/`values` are both {outerId: {innerKey: value}}, and
// two queued saves that both touch `inputs` (two different slots, or even
// two fields on the SAME slot from models.js's per-call-fresh objects) had
// the second's `inputs` object silently REPLACE the first's rather than
// combine with it -- the earlier edit vanished with no error. Merges one
// level deeper than Object.assign/spread does, for exactly the two keys
// that are ever shaped this way.
function mergeBodies(a, b) {
const out = { ...(a || {}) };
Object.entries(b || {}).forEach(([key, val]) => {
const prior = out[key];
const bothPlainObjects = val && typeof val === "object" && !Array.isArray(val)
&& prior && typeof prior === "object" && !Array.isArray(prior);
if (!bothPlainObjects) { out[key] = val; return; }
const merged = { ...prior };
Object.entries(val).forEach(([innerId, inner]) => {
const priorInner = merged[innerId];
merged[innerId] = (inner && typeof inner === "object" && !Array.isArray(inner)
&& priorInner && typeof priorInner === "object" && !Array.isArray(priorInner))
? { ...priorInner, ...inner }
: inner;
});
out[key] = merged;
});
return out;
}
// One shared save queue for every caller. `body` is layered onto the
// CURRENT `slots` (the one shared array every panel mutates through its
// own edits) each time a request actually goes out -- so a Models &
// Pipeline node-input edit and an Engine Settings module-value edit, made
// moments apart, both survive: neither's save can send a snapshot that
// predates the other's. See engine_settings.js's original fix for why the
// retry-after-in-flight shape exists (a same-panel double-edit dropped
// one edit before this).
// Set by whoever ties the pipeline to a project: a reason when an edit now would land on a stand-in it could not keep.
let editGuard = () => null;
const blocked = (kind = "edit") => { const why = editGuard(kind); if (why) { saveNotes = [why]; _changed("load"); } return why; }; // said, and views redraw to what is true
// A typed node value is live at once: a run reads these slots now, and the server's answer only confirms it.
function placeInputs(inputs) {
if (!inputs || !slots) return;
Object.entries(inputs).forEach(([id, kv]) => {
const slot = slots.find((x) => x.id === id);
if (slot && kv && typeof kv === "object" && !Array.isArray(kv)) slot.inputs = { ...(slot.inputs || {}), ...kv };
});
}
// An answer replaces the slots with the server's copy of what was SENT: whatever was typed while it was on its way
// (node values still queued, module values) goes back on, or the screen and the next run lose it.
// A value typed for a node that the answer swapped for another belongs to nothing now: it is dropped, not put on the
// new node (which has no such input, and would be refused for it).
function keepTyped(before) {
const was = new Map((before || []).map((x) => [x.id, x.node]));
const typed = pendingBody && pendingBody.inputs;
if (typed && slots) for (const id of Object.keys(typed)) {
const now = slots.find((x) => x.id === id);
if (!now || (was.has(id) && was.get(id) !== now.node)) delete typed[id];
}
placeInputs(typed);
if (Object.keys(pendingValues).length) mirrorValues(currentValues());
}
async function save(body) {
if (adoptGate) await adoptGate; // an edit made while a project's pipeline goes in lands on THAT pipeline
if (blocked()) return;
placeInputs(body && body.inputs);
pendingBody = mergeBodies(pendingBody, body);
if (saving) { pending = true; return; }
saving = true;
do {
pending = false;
applyGroups();
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;
const before = slots;
if (res && res.slots) slots = res.slots;
keepTyped(before); // an edit queued behind this one stays in the slots a run reads
applyGroups();
incomplete = (res && res.incomplete) || [];
refused = (res && res.refused) || [];
queueable = !!(res && res.queueable);
saveNotes = (res && res.notes) || [];
_changed("edit"); // now: the project this edit was made in takes it, even if another opens during the probe
// A no-op for the common case (same file as last time) -- see
// refreshManifest()'s own guard. Only a Models & Pipeline edit that
// actually changes the loader's file does a second round trip here.
// Its own try/catch: a failure here is not "could not save" -- the
// edit above already landed -- so it must not overwrite saveNotes
// with a message about the wrong failure.
let fresh = false;
try {
fresh = await refreshManifest();
} catch (e) {
console.warn(`[FunPack] could not refresh modules for the new model: ${e && e.message ? e.message : e}`);
}
if (fresh && mine === epoch) _changed("edit"); // again once a new model's modules are known: a listener sees those
} catch (e) {
saveNotes = [`Could not save: ${e && e.message ? e.message : e}`];
}
} while (pending);
saving = false;
}
// Edits already sent toward the server but not yet confirmed by a landed
// `slots` response. valuesAlreadyPlaced() only reflects an edit AFTER its
// save's response arrives -- without this overlay, a second setModuleValue
// call fired before the first one's round-trip resolves would compute its
// own "whole tree" snapshot from data that does not include the first
// edit yet, and silently revert it when its own write lands. Cleared per
// key once valuesAlreadyPlaced() actually agrees with it (see
// _reconcilePending), not wholesale on every resolve -- clearing the
// whole thing on any one save's completion would erase a DIFFERENT edit
// that is still in flight.
let pendingValues = {};
function _reconcilePending() {
if (saving) return; // a save still on its way may answer with older values: judge only once the queue is empty
const already = valuesAlreadyPlaced();
Object.keys(pendingValues).forEach((moduleId) => {
const pend = pendingValues[moduleId];
const real = already[moduleId] || {};
Object.keys(pend).forEach((name) => {
if (real[name] === pend[name] || (typeof pend[name] === "object" && JSON.stringify(real[name]) === JSON.stringify(pend[name]))) delete pend[name];
});
if (!Object.keys(pend).length) delete pendingValues[moduleId];
});
}
// The full module-settings tree as it stands right now: every installed
// module's own defaults, then whatever the graph already holds wins (same
// recovery valuesAlreadyPlaced() exists for), then any not-yet-confirmed
// edit wins over that. Always computed fresh rather than cached, so two
// editors of the same tree (Engine Settings, the sampler quick-access bar)
// can never hold a stale copy of each other's CONFIRMED state between
// them -- the pending overlay above is the one deliberate exception,
// needed so an in-flight edit from either editor isn't lost by the other.
// `from`: a pipeline other than the live one (Generate's, frozen at the click); edits not yet landed then do not count.
function currentValues(from) {
const merged = {};
Object.values(modulesById).forEach((m) => {
const own = {};
Object.entries(m.settings || {}).forEach(([name, spec]) => { own[name] = spec.default; });
if (Object.keys(own).length) merged[m.id] = own;
});
const already = valuesAlreadyPlaced(from || slots);
Object.entries(already).forEach(([moduleId, own]) => {
merged[moduleId] = { ...(merged[moduleId] || {}), ...own };
});
Object.entries(from ? {} : pendingValues).forEach(([moduleId, own]) => {
merged[moduleId] = { ...(merged[moduleId] || {}), ...own };
});
return merged;
}
// Which modules this project turned off (a reserved entry of the same settings tree), and which
// the server has quarantined after a fault. Neither shows in Engine settings or the quick bar.
let controlState = {};
function isOff(id) {
const off = (currentValues()._off || {}).modules;
return Array.isArray(off) && off.includes(id);
}
function setOff(id, off) {
const cur = new Set(((currentValues()._off || {}).modules) || []);
if (off) cur.add(id); else cur.delete(id);
return setModuleValue("_off", "modules", [...cur]);
}
// "Disable all enhancements": one project switch that makes every run plain, for A/B tests. The per-module
// choices are kept underneath, so switching it back restores exactly what was on.
const allOff = (from) => Boolean((currentValues(from)._off || {}).all);
const setAllOff = (on) => setModuleValue("_off", "all", Boolean(on));
// The screen is the truth: an edit is written into the live slots the moment it is made (the same blob the
// server's place() writes), so a run started now reads what is shown without waiting for any round trip.
function mirrorValues(values) {
const known = new Set([...Object.keys(modulesById), "_off"]);
(slots || []).forEach((slot) => {
// a settings node just added starts with no input at all
if (sinks) sinks.forEach((k) => { if (k.node === slot.node && !((slot.inputs ||= {})[k.input] !== undefined)) slot.inputs[k.input] = "{}"; });
Object.entries(slot.inputs || {}).forEach(([key, v]) => {
if (sinks) { if (isSink(slot, key) && !Array.isArray(v)) slot.inputs[key] = JSON.stringify(values); return; } // a fresh "{}" too
if (typeof v !== "string") return;
let parsed;
try { parsed = JSON.parse(v); } catch (_) { return; }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
if (Object.keys(parsed).some((id) => known.has(id))) slot.inputs[key] = JSON.stringify(values);
});
});
}
// A settings node with no settings yet (just added, swapped in, or from a snapshot taken before it had any):
// given the project's values, or the run would not be queueable. -> whether one was.
function fillEmptySinks(values) {
const empty = (sinks || []).some((k) => (slots || []).some((s) => s.node === k.node && [undefined, ""].includes((s.inputs || {})[k.input])));
if (empty) mirrorValues(values);
return empty;
}
async function refreshControl() {
try { controlState = (await API.modules()).control || {}; } catch (_) { /* keeps the last answer */ }
return controlState;
}
function isQuarantined(id) { return !!(controlState[id] && controlState[id].quarantine); }
// Whether anything would act on a module's settings now: its own node is in the pipeline (when it has one),
// and every capability it offers (the modifier loader, the taste store) reaches a user that is switched on for this
// project, in a mode that asks for it (its uses_when), has every node it needs in the pipeline, and is itself acted on (a taste learner
// the loader never installs is not). "Disable all enhancements" is not counted: it sits on top, and the Modules page
// still lists what is kept underneath it. -> a predicate; the values are read once for a whole list.
function usefulness() {
const values = currentValues(), list = (values._off || {}).modules, off = new Set(Array.isArray(list) ? list : []);
const users = Object.values(modulesById).filter((u) => (u.uses || []).length && !off.has(u.id) && !isQuarantined(u.id));
const has = (n) => !slots || slots.some((s) => s.node === n);
const filled = (at) => !slots || slots.some((s) => (s.roles || []).some((r) => r.at === at));
const check = (m, seen) => {
const working = (u) => {
const v = values[u.id] || {};
return !seen.has(u.id) && (!("enabled" in (u.settings || {})) || v.enabled) && Object.entries(u.uses_when || {}).every(([k, want]) => (Array.isArray(want) ? want : [want]).includes(v[k]))
&& (u.nodes || []).every(has) && check(u, new Set([...seen, u.id]));
};
return (!(m.nodes || []).length || m.nodes.some(has)) && (!(m.roles || []).length || m.roles.some(filled))
&& (m.serves || []).every((cap) => users.some((u) => u.uses.includes(cap) && working(u)));
};
return (m) => check(m, new Set([m.id]));
}
const useful = (m) => usefulness()(m);
function activeModules() {
if (allOff()) return [];
const list = (currentValues()._off || {}).modules, off = new Set(Array.isArray(list) ? list : []); // read once, not once per module
return Object.values(modulesById).filter((m) => !off.has(m.id) && !isQuarantined(m.id));
}
// Patch ONE field and save the WHOLE tree. place() (core/graph.py) writes
// values as a single opaque JSON blob with no merge on the server side --
// sending only the module being edited would silently erase every other
// module's settings from that blob. Every write goes through here so that
// rule lives in one place instead of being re-derived per caller.
// pendingValues is updated SYNCHRONOUSLY, before the read below, so a
// second setModuleValue call made while this one is still in flight sees
// this edit too (see currentValues()) -- that is what actually closes the
// race, not save()'s own body-merge queue, which only merges DELTAS and
// has nothing to merge against once a caller is sending a full snapshot.
//
// The returned promise is NOT "this specific edit is server-confirmed" --
// save()'s queue has exactly one caller actually await the network (the
// one that finds `saving` false); every other concurrent caller's save()
// call returns as soon as it queues, before its own write is sent, let
// alone answered. Harmless today: both real callers (engine_settings.js,
// sampler_quickbar.js) only do `.then(render)`, and render() reads
// currentValues(), which already carries this edit optimistically via
// pendingValues regardless of where the real network call is. A future
// caller that needs "my write actually landed" (a per-edit success
// toast, reading saveNotes()/incomplete() right after resolve) cannot
// get that from this promise as written -- it would need save() itself
// reworked to resolve each queued caller at ITS OWN write's landing, not
// at whichever write happens to be in flight when the queue drains.
async function setModuleValue(moduleId, name, value) {
if (adoptGate) await adoptGate; // the values tree must be the pipeline this lands on, not the one going out
if (blocked()) return;
pendingValues[moduleId] = { ...(pendingValues[moduleId] || {}), [name]: value };
const values = currentValues();
mirrorValues(values);
return save({ values }).then(_reconcilePending);
}
// A change to the pipeline's SHAPE (add / replace / remove / wire / unwire), sent on its own: two
// of these folded into one request would lose the first. Waits for any value edit in flight and
// for a project's pipeline going in. -> {refused: [...]}; empty when it happened.
async function editNow(body) {
await ensureLoaded();
if (slots === null) return { refused: ["The pipeline has not loaded: is ComfyUI reachable?"] };
if (adoptGate) await adoptGate;
{ const why = blocked("shape"); if (why) return { refused: [why] }; }
for (let waited = 0; saving && waited < 10000; waited += 20) await new Promise((r) => setTimeout(r, 20));
if (saving) return { refused: ["A save is still in progress: try again in a moment."] };
const keep = currentValues(); // a settings node added or swapped in starts empty: it gets these
let refill = false;
const mine = epoch;
saving = true;
let refusedNow = [], notesNow = [];
try {
applyGroups();
const res = await API.editPipeline({ slots, ...body });
if (mine !== epoch) return { refused: ["The project was changed while this was being sent."] };
refusedNow = (res && res.refused) || [];
if (res && res.slots && !refusedNow.length) { const before = slots; slots = res.slots; keepTyped(before); applyGroups(); }
incomplete = (res && res.incomplete) || [];
refused = refusedNow;
queueable = !!(res && res.queueable);
saveNotes = notesNow = (res && res.notes) || [];
if (!refusedNow.length) {
if (body.action === "remove" && offered.some((s) => s.id === body.slot)) removed.add(body.slot); // the person's own additions just go
if (body.action === "add") (res.slots || []).forEach((s) => removed.delete(s.id));
if (body.action === "unwire" && offered.some((s) => s.id === body.slot)) {
unwired[body.slot] = [...new Set([...(unwired[body.slot] || []), body.input])];
}
if (body.action === "wire" && unwired[body.slot]) {
unwired[body.slot] = unwired[body.slot].filter((k) => k !== body.input);
if (!unwired[body.slot].length) delete unwired[body.slot];
}
if (body.action === "remove") { delete unwired[body.slot]; delete groupEdits[body.slot]; }
// A default slot that was swapped for the same node, or taken out and put back, starts empty:
// none of the default's links may come back over it when the project is opened again.
const fresh = body.action === "replace" ? [body.slot] : body.action === "add" ? (res.slots || []).map((s) => s.id) : [];
fresh.forEach((id) => {
const def = offered.find((s) => s.id === id);
if (!def) return;
const links = Object.keys(def.inputs || {}).filter((k) => Array.isArray(def.inputs[k]));
if (links.length) unwired[id] = [...new Set([...(unwired[id] || []), ...links])];
});
try { await refreshManifest(); } catch (_) { /* the next save retries it */ }
refill = fillEmptySinks(keep);
_changed("shape"); // a change of shape: may replace a refused saved pipeline
}
} catch (e) {
refusedNow = [e && e.message ? e.message : String(e)];
} finally {
saving = false;
}
if (refill || pending) await save({}); // the filled settings, or a value edit queued behind this one
return { refused: refusedNow, notes: notesNow }; // notes: this edit's own (a node added but left unconnected), not a later save's
}
// The pipeline as it stands, to come back to: Models & Pipeline's Cancel puts this back.
function snapshot() {
if (slots === null) return null;
return { slots: JSON.parse(JSON.stringify(slots)), removed: [...removed], unwired: JSON.parse(JSON.stringify(unwired)), whole };
}
// -> {refused: [...]}; empty when the snapshot is back. Announced as a change, so the project follows.
async function restoreNow(snap) {
if (!snap || !Array.isArray(snap.slots)) return { refused: ["Nothing to go back to."] };
// Already what is live: nothing to do. Above all not a change of shape, which would let Revert replace a refused saved pipeline.
if (JSON.stringify(snap.slots) === JSON.stringify(slots)) return { refused: [] };
if (adoptGate) await adoptGate;
{ const why = blocked("shape"); if (why) return { refused: [why] }; }
for (let waited = 0; saving && waited < 10000; waited += 20) await new Promise((r) => setTimeout(r, 20));
if (saving) return { refused: ["A save is still in progress: try again in a moment."] };
const keep = currentValues();
let refill = false;
const mine = epoch;
saving = true;
let refusedNow = [];
try {
const res = await API.editPipeline({ slots: JSON.parse(JSON.stringify(snap.slots)) });
if (mine !== epoch) return { refused: ["The project was changed while this was being sent."] };
refusedNow = (res && res.refused) || [];
if (res && res.slots && !refusedNow.length) {
const before = slots;
slots = res.slots;
keepTyped(before);
// A default slot the new pipeline lacks is removed, whatever the caller said: otherwise the next
// open lays the default back over it (a preset, an import or the wizard would regain the default's loaders).
const kept = new Set(res.slots.map((s) => s.id));
removed = new Set([...(snap.removed || []), ...(offered || []).map((s) => s.id).filter((id) => !kept.has(id))]);
whole = snap.whole !== false; // only a snapshot of an edited default says otherwise
unwired = JSON.parse(JSON.stringify(snap.unwired || {}));
groupEdits = {}; pendingValues = {}; pendingBody = null; pending = false;
incomplete = res.incomplete || []; refused = []; queueable = !!res.queueable;
saveNotes = res.notes || [];
try { await refreshManifest(); } catch (_) { /* the next save retries it */ }
refill = fillEmptySinks(keep);
_changed("shape"); // a change of shape: may replace a refused saved pipeline
}
} catch (e) {
refusedNow = [e && e.message ? e.message : String(e)];
} finally {
saving = false;
}
if (refill) await save({});
return { refused: refusedNow };
}
// A slot's group is the person's own to name (any node in any group).
// Kept until it has landed: a save already in flight answers with the slots as they were when
// it left, and would otherwise put the old group back.
let groupEdits = {};
function applyGroups() {
(slots || []).forEach((s) => {
if (!(s.id in groupEdits)) return;
if (groupEdits[s.id]) s.group = groupEdits[s.id]; else delete s.group;
});
}
function setGroup(slotId, group) {
if (!(slots || []).some((s) => s.id === slotId)) return Promise.resolve();
groupEdits[slotId] = String(group || "").trim();
applyGroups();
const mine = groupEdits[slotId];
// save() returns once queued behind a save in flight: let go of the move only when the queue has landed
return save({}).then(() => settled()).then(() => { if (groupEdits[slotId] === mine) delete groupEdits[slotId]; });
}
// Whoever keeps the pipeline with the project hears every landed edit.
const listeners = new Set();
// kind: "edit" (a change the person made landed) or "load" (a project's pipeline went in, or the module list came).
function _changed(kind = "edit") { listeners.forEach((fn) => { try { fn(slots, kind); } catch (e) { console.error(e); } }); }
// Put a project's saved pipeline over the one the server offers: the person's inputs (model
// files, node values, the module-settings blob), group and bypass win on every slot the
// default still has; slots they added are kept. The server's own defaults fill in whatever
// the saved copy predates, so an update's new settings are not lost to an old project.
// Announced once it is in, so views redraw; the project link writes nothing while a project is going in.
// True when the project's pipeline is in (or it had none to put in); false when it could not be.
// `inherit`: a project with nothing saved keeps the live pipeline (a new one); otherwise it gets the default.
async function adopt(saved, removedIds, unwiredMap, wholeSaved, inherit = true) {
let open;
const gate = new Promise((r) => { open = r; }), before = adoptGate;
adoptGate = gate; // before anything awaits: an edit right behind this waits too
adopting += 1;
deferred = null; // an earlier project's pipeline waiting for the load is not this one's
try {
if (before) await before; // one at a time: a project opened behind another is put in after it, never under it
await ensureLoaded();
return await _adopt(saved, removedIds, unwiredMap, wholeSaved, inherit);
} finally { adopting -= 1; if (adoptGate === gate) adoptGate = null; open(); _changed("load"); }
}
/** Resolves true once no edit is on its way and no project's pipeline is going in; false if that took over
* three minutes (every request gives up after ANSWER_MS, so only a server failing again and again gets there). */
// Counted from the moment one is asked for, not from when it gets its turn behind a save: a run started in between
// would freeze the pipeline as it was before the change.
const shape = (fn) => async (...args) => { shaping += 1; try { return await fn(...args); } finally { shaping -= 1; } };
const edit = shape(editNow), restore = shape(restoreNow);
// What a run needs finished: a project's pipeline going in, or a node swapped/added/removed (that shape exists only once
// the server answers). A value save is not waited on: the value is already in the slots (placeInputs, mirrorValues).
async function readyToRun() {
for (let waited = 0; (shaping || adoptGate) && waited < 180000; waited += 20) await new Promise((r) => setTimeout(r, 20));
return !(shaping || adoptGate);
}
async function settled() {
for (let waited = 0; (saving || adoptGate) && waited < 180000; waited += 20) await new Promise((r) => setTimeout(r, 20));
return !(saving || adoptGate);
}
async function _adopt(saved, removedIds, unwiredMap, wholeSaved, inherit) {
// 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.
const hadSomething = (Array.isArray(saved) && saved.length) || (Array.isArray(removedIds) && removedIds.length);
if (slots === null) { if (hadSomething) deferred = [saved, removedIds, unwiredMap, wholeSaved]; return (adoptedOk = !hadSomething); }
if (!hadSomething && inherit) { saveNotes = []; return (adoptedOk = true); } // a new project: no note about the last one's load
if (!Array.isArray(saved)) saved = [];
// 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)
&& (s.group === undefined || (typeof s.group === "string" && s.group.trim()))
&& (s.roles === undefined || Array.isArray(s.roles));
const byId = new Map(saved.filter(sound).map((s) => [s.id, s]));
const have = new Set(offered.map((s) => s.id));
// Slots the person took out stay out; ones whose node they swapped keep their swap.
const gone = new Set(Array.isArray(removedIds) ? removedIds.filter((x) => typeof x === "string") : []);
// A whole pipeline comes back exactly as saved: laid over the default it would regain the default's slots
// and roles. Files saved before the flag existed: a whole one has slots of its own and lacks default ones
// nobody removed. ponytail: an edited default saved before an update added a default slot reads as whole
// too, and simply goes without that slot.
const isWhole = typeof wholeSaved === "boolean" ? wholeSaved
: [...byId.keys()].some((id) => !have.has(id)) && offered.some((d) => !gone.has(d.id) && !byId.has(d.id));
const lay = (extras) => isWhole ? JSON.parse(JSON.stringify(saved.filter(sound))) : offered.filter((def) => !gone.has(def.id)).map((def) => {
const mine = byId.get(def.id);
if (!mine) return JSON.parse(JSON.stringify(def));
if (mine.node !== def.node) return JSON.parse(JSON.stringify(mine));
// A default LINK the saved copy lacks was unwired by the person; a default VALUE it lacks is
// one an update added, and fills in.
const inputs = { ...(def.inputs || {}) };
const cut = (unwiredMap || {})[def.id];
Object.keys(inputs).forEach((k) => { if (Array.isArray(cut) && cut.includes(k) && !(k in mine.inputs)) delete inputs[k]; });
const out = { ...def, inputs: { ...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(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.
const mine = ++epoch;
pendingValues = {}; // the new project's values win, not an edit made to the old one
removed = new Set(gone);
whole = isWhole;
unwired = {};
Object.entries(unwiredMap && typeof unwiredMap === "object" ? unwiredMap : {}).forEach(([id, list]) => {
if (Array.isArray(list)) unwired[id] = list.filter((x) => typeof x === "string");
});
groupEdits = {};
pendingBody = null; pending = false;
for (let waited = 0; saving && waited < 10000; waited += 20) await new Promise((r) => setTimeout(r, 20));
let unreachable = false, reasons = [];
for (const extras of [true, false]) {
try {
const res = await API.editPipeline({ slots: lay(extras) });
if (mine !== epoch) return true; // a later project was opened meanwhile: not this one's to report
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 (adoptedOk = true);
}
reasons = (res && res.refused) || reasons;
} catch (e) {
// No answer, or a server error (a tunnel, a restarting ComfyUI): try again later. A 4xx is a refusal.
if (!e || !e.status || e.status >= 500) unreachable = true;
else reasons = [e.message];
}
}
unreachableLast = unreachable;
refusalLast = reasons.length ? String(reasons[0]) : null;
// Nothing saved was accepted (a failed request, or a file the server refuses): the default
// runs, said, and the caller must NOT save it over the project's own copy.
slots = JSON.parse(JSON.stringify(offered));
removed = new Set(); unwired = {}; whole = false; // the stand-in is the plain default: no flags of the project it could not load
incomplete = []; refused = []; queueable = false; // nor the last project's readiness
saveNotes = ["This project's saved pipeline could not be loaded, so the default is in use."];
return (adoptedOk = false);
}
return {
ensureLoaded, save, edit, restore, snapshot, setGroup, adopt, settled, readyToRun, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, valuesAlreadyPlaced, currentValues, setModuleValue,
modulesById: () => modulesById,
setEditGuard: (fn) => { editGuard = fn; }, adoptedOk: () => adoptedOk, unreachable: () => slots === null || unreachableLast, refusal: () => refusalLast, activeModules, useful, usefulness, isOff, setOff, allOff, setAllOff, refreshControl, control: () => controlState,
removedIds: () => [...removed],
whole: () => whole,
unwiredMap: () => JSON.parse(JSON.stringify(unwired)),
slots: () => slots,
incomplete: () => incomplete,
refused: () => refused,
queueable: () => queueable,
loading: () => loading,
loadError: () => loadError,
saving: () => saving,
saveNotes: () => (modelNote ? [...saveNotes, modelNote] : saveNotes),
};
}