// 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 = { FunUICheckpointLoader: "ckpt_name", FunUIDiffusionModelLoader: "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 modelBlocks = null; // how many transformer blocks the chosen file has, when its header says 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; modelBlocks = null; if (!filename) return null; try { const data = await API.probeFamily(filename); modelBlocks = Number.isInteger(data.blocks) && data.blocks > 0 ? data.blocks : null; 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) let lastTraits; // the traits the module list was last asked for let installed = null; // ids of every module that loaded (null until the server says): saved settings for any other id belong to nothing // Ask again for the modules, for the choices a setting reads from disk (a folder of files) that may have changed. async function reloadModules() { try { const manifest = await API.modules(lastTraits ? lastTraits.join(",") : undefined); if (!Array.isArray(manifest.modules)) return false; modulesById = {}; manifest.modules.forEach((m) => { modulesById[m.id] = m; }); installed = Array.isArray(manifest.installed) && !(manifest.failed || []).length ? new Set(manifest.installed) : null; // a module that failed to load may be back next time: its settings are only cleared when none failed return true; } catch (_) { return false; } // keeps the list it has } 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); lastTraits = traits; modulesById = {}; (manifest.modules || []).forEach((m) => { modulesById[m.id] = m; }); controlState = manifest.control || controlState; installed = Array.isArray(manifest.installed) && !(manifest.failed || []).length ? new Set(manifest.installed) : null; // a module that failed to load may be back next time: its settings are only cleared when none failed 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 = []; // Optional nodes the server left out of the run because their module did not load: { slotId: { slot, node, keeps } }. // The controls that belong to them are hidden (see the slots accessor); the saved pipeline still has them. let skipped = {}; const skipMap = (r) => Object.fromEntries(((r && r.skipped) || []).map((x) => [x.slot, x])); const seen = (s0) => { const k = skipped[s0.id]; return k && s0.roles ? { ...s0, roles: s0.roles.filter((r) => (k.keeps || []).includes(r.input)) } : s0; }; // 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 (installed && moduleId !== "_off" && !installed.has(moduleId)) return; // a removed or renamed module's settings leave the project at the next save if (own && typeof own === "object") merged[moduleId] = { ...(merged[moduleId] || {}), ...own }; }); }); }); return merged; } // Module ids a saved settings blob names that no loaded module has (a module removed or renamed since the project was saved). function staleModuleIds(from = slots) { if (!installed || !sinks) return []; const out = new Set(); (from || []).forEach((slot) => Object.entries(slot.inputs || {}).forEach(([key, v]) => { if (typeof v !== "string" || !isSink(slot, key)) return; let parsed; try { parsed = JSON.parse(v); } catch (_) { return; } if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) Object.keys(parsed).forEach((id) => { if (id !== "_off" && !installed.has(id)) out.add(id); }); })); return [...out]; } // 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 || []; skipped = skipMap(pipe); 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) || []; skipped = skipMap(res); 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(`[FunUI] 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]) => { // A saved key its module no longer declares (removed or renamed) is dropped here, so it leaves the project at the next save. const declared = modulesById[moduleId] && modulesById[moduleId].settings; const kept = declared ? Object.fromEntries(Object.entries(own).filter(([name]) => name in declared)) : own; merged[moduleId] = { ...(merged[moduleId] || {}), ...kept }; }); 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)); // "Disable automatic camera prompt enhancement": Shot camera's rewrites only; every other enhancement stays as set. const cameraOff = (from) => Boolean((currentValues(from)._off || {}).camera); const setCameraOff = (on) => setModuleValue("_off", "camera", Boolean(on)); // "Disable all guidance enhancements": every module of the Guidance category sits out; their choices are kept underneath. const guidanceOff = (from) => Boolean((currentValues(from)._off || {}).guidance); const setGuidanceOff = (on) => setModuleValue("_off", "guidance", 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) || []; skipped = skipMap(res); 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]; delete bypassEdits[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 = {}; bypassEdits = {}; pendingValues = {}; pendingBody = null; pending = false; incomplete = res.incomplete || []; refused = []; queueable = !!res.queueable; skipped = skipMap(res); 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 = {}; let bypassEdits = {}; // same life as a group edit: kept until its save has landed function applyGroups() { (slots || []).forEach((s) => { if (s.id in bypassEdits) { if (bypassEdits[s.id]) s.bypassed = true; else delete s.bypassed; } if (!(s.id in groupEdits)) return; if (groupEdits[s.id]) s.group = groupEdits[s.id]; else delete s.group; }); } /** Switch a node off for the run (what it feeds reads what fed it) without losing a single value on it. */ function setBypass(slotId, on) { if (!(slots || []).some((s) => s.id === slotId)) return Promise.resolve(); bypassEdits[slotId] = Boolean(on); applyGroups(); const mine = bypassEdits[slotId]; return save({}).then(() => settled()).then(() => { if (bypassEdits[slotId] === mine) delete bypassEdits[slotId]; }); } 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)); // A pipeline started from a preset lacks the media inputs an update gave that preset since (a video or audio // reference slot): those take no setting and nothing else depends on them, so they are laid in beside the saved ones. let grown = []; try { const isAsset = (s) => (s.roles || []).length && s.roles.every((r) => String(r.at || "").startsWith("assets.")); // Which preset it came from is read off the slots it still shares: the first preset with ONE matching input // slot is not it (an Image to Video preset shares "source_image" with Video to Video, and its identity // loader then landed in a pipeline that has no identity node to feed). const shared = (pr) => (pr.slots || []).filter((s) => byId.has(s.id)).length; const from = ((await API.pipelinePresets()).presets || []).filter((pr) => (pr.slots || []).some((s) => isAsset(s) && byId.has(s.id))).sort((a, b) => shared(b) - shared(a))[0]; const feeds = (s) => (s.roles || []).every((r) => !r.wireTo || byId.has(r.wireTo.slot) || have.has(r.wireTo.slot)); // an input is laid in only where what it feeds is there if (from) grown = from.slots.filter((s) => isAsset(s) && sound(s) && !byId.has(s.id) && !gone.has(s.id) && feeds(s)); } catch (_) { /* the saved pipeline goes in as it is */ } const lay = (extras) => isWhole ? JSON.parse(JSON.stringify([...saved.filter(sound), ...(extras ? grown : [])])) : 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)), ...grown.filter((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 = {}; bypassEdits = {}; 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; skipped = skipMap(res); 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 */ } if (staleModuleIds().length) { const values = currentValues(); mirrorValues(values); save({ values }); } // settings of modules that no longer exist are cleared from the project now, not at some later edit 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)); skipped = {}; // the stand-in is the default pipeline: whatever the last project skipped says nothing about it 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, reloadModules, save, edit, restore, snapshot, setGroup, setBypass, 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, cameraOff, setCameraOff, guidanceOff, setGuidanceOff, refreshControl, control: () => controlState, removedIds: () => [...removed], whole: () => whole, unwiredMap: () => JSON.parse(JSON.stringify(unwired)), slots: () => slots, /** The slots as the controls should show them: a skipped node keeps only the controls its pass-through needs. Read-only; never save this. */ view: () => (slots && Object.keys(skipped).length ? slots.map(seen) : slots), skipped: () => skipped, /** Whether a module is here and switched on for this project (its own feature may still be off in its settings). */ hasModule: (id) => Boolean(modulesById[id]) && !isOff(id) && !isQuarantined(id), /** The traits the chosen model has (null: not known, so nothing is hidden for it). */ traits: () => lastTraits || null, /** How many transformer blocks the chosen model file has (null: not known). */ blocks: () => modelBlocks, incomplete: () => incomplete, refused: () => refused, queueable: () => queueable, loading: () => loading, loadError: () => loadError, saving: () => saving, saveNotes: () => (modelNote ? [...saveNotes, modelNote] : saveNotes), }; }