diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index cf7fce51..96b8b7a5 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -57,6 +57,7 @@ export default { let made = 0; said = false; try { + if (app.pipeline.settled) await app.pipeline.settled(); // a project's pipeline still going in is the one this run uses, not the last one's const snap = times > 1 ? structuredClone(p.project) : null; // takes differ in the seed alone: later edits to the project do not reach them const drawn = new Map(); const same = times > 1 ? (body) => { const key = JSON.stringify(body); if (!drawn.has(key)) drawn.set(key, expand(body)); return drawn.get(key); } : expand; diff --git a/app/ui/features/sampler/hub.test.js b/app/ui/features/sampler/hub.test.js index 21d16140..13dcd2d3 100644 --- a/app/ui/features/sampler/hub.test.js +++ b/app/ui/features/sampler/hub.test.js @@ -43,3 +43,24 @@ test("when the sampler node's options cannot be read, the window says where to c await new Promise((r) => setTimeout(r, 0)); assert.match(document.body.textContent, /could not be read from the sampler node/); }); + +test("typing a value back to what the window opened with is saved, though the window was not redrawn in between", async () => { + let live = [{ id: "sampler", node: "S", inputs: { steps: 7 }, roles: [{ at: "generation.sampling", input: "steps", label: "Steps" }] }]; + const saved = []; + const ps = { slots: () => live, activeModules: () => [], useful: () => true, usefulness: () => () => true, currentValues: () => ({}), loading: () => false, loadError: () => null, + subscribe: () => () => {}, ensureLoaded: async () => {}, + save: async (b) => { saved.push(b.inputs.sampler.steps); live = [{ ...live[0], inputs: { ...live[0].inputs, ...b.inputs.sampler } }]; } }; + const host = document.createElement("div"); + document.body.append(host); + hub.setup({ host, app: { pipeline: ps, api: { describeNodes: async () => ({ nodes: { S: { widgets: [{ name: "steps", type: "INT", min: 1, max: 100 }] } } }) }, on: () => () => {}, actions: null } }); + await new Promise((r) => setTimeout(r, 0)); + host.querySelector("button").click(); + await new Promise((r) => setTimeout(r, 0)); + const input = [...document.querySelectorAll("input[type=number]")].pop(); + for (const v of ["20", "7"]) { + input.value = v; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + } + assert.deepStrictEqual(saved, [20, 7]); +}); diff --git a/app/ui/features/sampler/index.js b/app/ui/features/sampler/index.js index 8af62430..fa6cd79c 100644 --- a/app/ui/features/sampler/index.js +++ b/app/ui/features/sampler/index.js @@ -22,7 +22,10 @@ export default { const inputRow = ({ slot, role }) => { const w = ((specs[slot.node] || {}).widgets || []).find((x) => x.name === role.input); if (!w) return null; - const set = async (v) => { if (JSON.stringify((slot.inputs || {})[role.input]) !== JSON.stringify(v)) await ps.save({ inputs: { [slot.id]: { [role.input]: v } } }); }; + const set = async (v) => { // compared with the slot as it is NOW: the one drawn may be several saves old + const now = (ps.slots() || []).find((s) => s.id === slot.id) || slot; + if (JSON.stringify((now.inputs || {})[role.input]) !== JSON.stringify(v)) await ps.save({ inputs: { [slot.id]: { [role.input]: v } } }); + }; return c.settingsRow.default({ label: role.label || w.name, hint: w.tooltip || "", control: widgetControl({ ...w, name: role.label || w.name }, (slot.inputs || {})[role.input] ?? w.default, set) }); }; const button = c.button.sm({ label: "⏱ Sampler", tone: "neutral", title: "Steps, sampler, scheduler and sampling settings: one click, no modal.", onClick: () => (pop ? pop.close() : open()) }); diff --git a/app/ui/features/settings/index.js b/app/ui/features/settings/index.js index 71fb4b35..63164539 100644 --- a/app/ui/features/settings/index.js +++ b/app/ui/features/settings/index.js @@ -31,9 +31,15 @@ export default { add("refinement", "Refinement & Taste", "Learned-taste state: refinement keys.", "✦", "danger", taste(app), "Learning", "refinement taste keys rating"); // only when that module is installed: a section for something absent would be a dead end - app.pipeline.ensureLoaded().then(() => { - if (app.pipeline.modulesById()["conditioning_shot_camera"]) add("shotmemory", "Shot camera memory", "What the camera has learned from your prompts and ratings.", "◎", "neutral", shotMemory(app), "Learning", "shot camera views moves words forget"); - }); + // checked again on every pipeline change: ComfyUI may be down when the page loads and answer later + let shot = false; + const offerShot = () => { + if (shot || !app.pipeline.modulesById()["conditioning_shot_camera"]) return; + shot = true; + add("shotmemory", "Shot camera memory", "What the camera has learned from your prompts and ratings.", "◎", "neutral", shotMemory(app), "Learning", "shot camera views moves words forget"); + }; + app.pipeline.ensureLoaded().then(offerShot); + app.pipeline.subscribe(offerShot); add("system", "Updates & ComfyUI", "Server connection, FunPack code updates, pipeline health.", "⟳", "good", system(app), "System", "update git branch restart rollback"); add("customnodes", "Custom Nodes", "Install, update and remove ComfyUI node packs.", "⧉", "neutral", diff --git a/app/ui/shell/pipeline_state.js b/app/ui/shell/pipeline_state.js index 3f703b3b..64717472 100644 --- a/app/ui/shell/pipeline_state.js +++ b/app/ui/shell/pipeline_state.js @@ -7,7 +7,24 @@ // 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. -export function createPipelineState(API) { +// 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 @@ -58,6 +75,7 @@ export function createPipelineState(API) { (manifest.modules || []).forEach((m) => { modulesById[m.id] = m; }); controlState = manifest.control || controlState; 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 = []; @@ -112,7 +130,11 @@ export function createPipelineState(API) { let loadPromise = null; async function ensureLoaded() { - if (slots !== null) return; // session-wide: every consumer shares this one load + 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(); }, () => { loadPromise = null; }); + return loadPromise; + } if (loadPromise) return loadPromise; loading = true; loadError = null; loadPromise = (async () => { @@ -304,7 +326,7 @@ export function createPipelineState(API) { 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, not set to a manual value, has every node it needs in the pipeline, and is itself acted on (a taste learner + // 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() { @@ -314,7 +336,7 @@ export function createPipelineState(API) { const check = (m, seen) => { const working = (u) => { const v = values[u.id] || {}; - return !seen.has(u.id) && (!("enabled" in (u.settings || {})) || v.enabled) && v.mode !== "manual" + 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)) @@ -490,6 +512,7 @@ export function createPipelineState(API) { 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(); diff --git a/app/ui/shell/tests/pipeline_state.test.js b/app/ui/shell/tests/pipeline_state.test.js index 6c7fb1a9..9ecc9f53 100644 --- a/app/ui/shell/tests/pipeline_state.test.js +++ b/app/ui/shell/tests/pipeline_state.test.js @@ -20,7 +20,7 @@ function load(posts, opts = {}) { probeFamily: async () => ({}), }; lastApi = API; - return createPipelineState(API); + return createPipelineState(API, { answerMs: opts.answerMs }); } test("a project's saved pipeline lays its values over the server's default, keeping what the default added", async () => { @@ -330,11 +330,13 @@ test("an edited default stays a layer: the default's new values still fill in, a const withCheckpoint = (PS) => { lastApi.pipeline = async () => ({ slots: [{ id: "model", node: "FunPackCheckpointLoader", inputs: { ckpt_name: "a" } }], incomplete: [], refused: [], queueable: true }); return PS; }; test("a module's settings count only when something would read them: its own node, or a user of what it serves that is on", async () => { - const learner = (mode, enabled = true) => ({ id: "learner", settings: { enabled: { type: "bool", default: enabled }, mode: { type: "enum", default: mode } }, uses: ["taste_store"] }); + const learner = (mode, enabled = true) => ({ id: "learner", settings: { enabled: { type: "bool", default: enabled }, mode: { type: "enum", default: mode } }, uses: ["taste_store"], uses_when: { mode: "learned" } }); const cam = { id: "cam", nodes: ["CamNode"], settings: { on: { default: false } } }, taste = { id: "taste", serves: ["taste_store"], settings: { key: { default: "" } } }; const check = async (mods) => { const PS = withCheckpoint(load([])); lastApi.modules = async () => ({ modules: mods }); await PS.ensureLoaded(); const by = PS.modulesById(); return [PS.useful(by.cam), PS.useful(by.taste)]; }; assert.deepStrictEqual(await check([cam, taste, learner("learned")]), [false, true]); // the pipeline has no CamNode assert.deepStrictEqual(await check([cam, taste, learner("manual")]), [false, false]); // its one user is on a manual value + const always = { ...learner("manual"), uses_when: {} }; // a manual mode that still reads the store (shot memory) + assert.deepStrictEqual(await check([cam, taste, always]), [false, true]); assert.deepStrictEqual(await check([cam, taste, learner("learned", false)]), [false, false]); assert.deepStrictEqual(await check([{ ...cam, nodes: ["FunPackCheckpointLoader"] }, taste]), [true, false]); }); @@ -446,3 +448,40 @@ test("with every enhancement disabled, the modules kept underneath still count, assert.strictEqual(PS.allOff(), true); assert.strictEqual(PS.usefulness()(PS.modulesById().sharpen), true); }); + +test("a request that never answers gives up, so the pipeline loads once the server answers and edits go through", async () => { + const PS = load([], { answerMs: 50 }); + const real = lastApi.pipeline; + lastApi.pipeline = () => new Promise(() => {}); // hangs forever + const saved = [{ id: "model", node: "Loader", inputs: { file: "mine" } }]; + assert.strictEqual(await PS.adopt(saved), false); + lastApi.pipeline = real; + assert.strictEqual(await PS.adopt(saved), true); + assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "mine"); + await PS.save({ inputs: { model: { file: "next" } } }); + assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "next"); +}); + +test("a module list that failed once is asked for again, and the error clears when it comes", async () => { + const PS = load([]); + const real = async () => ({ modules: [{ id: "sharpen", settings: {} }] }); + lastApi.modules = async () => { throw new Error("HTTP 500"); }; + await PS.ensureLoaded(); + assert.ok(PS.loadError()); + lastApi.modules = real; + let heard = 0; + PS.subscribe(() => { heard += 1; }); + await PS.ensureLoaded(); + assert.deepStrictEqual([PS.loadError(), Object.keys(PS.modulesById()), heard], [null, ["sharpen"], 1]); +}); + +test("a project opened while ComfyUI is down never gets an earlier project's waiting pipeline", async () => { + const PS = load([]); + const real = lastApi.pipeline; + lastApi.pipeline = async () => { throw new Error("down"); }; + await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "A" } }]); + await PS.adopt([], [], {}, undefined, false); // an older project with nothing saved + lastApi.pipeline = real; + await PS.ensureLoaded(); + assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, ""); +}); diff --git a/core/contract.py b/core/contract.py index 25ea45e5..1c758775 100644 --- a/core/contract.py +++ b/core/contract.py @@ -60,6 +60,7 @@ class ModuleSpec: settings: Dict[str, dict] = field(default_factory=dict) requires: List[str] = field(default_factory=list) # model traits uses: List[str] = field(default_factory=list) # capabilities this module asks other modules for + uses_when: Dict[str, Any] = field(default_factory=dict) # its own setting values under which it asks (empty: whenever on) after: List[str] = field(default_factory=list) # module ids before: List[str] = field(default_factory=list) stage: str = "sampling" @@ -101,6 +102,7 @@ class ModuleSpec: "settings": self.settings, "requires": list(self.requires), "uses": list(self.uses), + "uses_when": dict(self.uses_when), "after": list(self.after), "before": list(self.before), "stage": self.stage, diff --git a/core/registry.py b/core/registry.py index 4aadcdbf..9efcb066 100644 --- a/core/registry.py +++ b/core/registry.py @@ -19,7 +19,7 @@ from .schema import SchemaError, validate # behaviour -- a module's announcement should be legible without running it. FIELDS = { "id": "ID", "title": "TITLE", "mount": "MOUNT", "settings": "SETTINGS", - "requires": "REQUIRES", "uses": "USES", "after": "AFTER", "before": "BEFORE", + "requires": "REQUIRES", "uses": "USES", "uses_when": "USES_WHEN", "after": "AFTER", "before": "BEFORE", "stage": "STAGE", "category": "CATEGORY", "status": "STATUS", "nodes": "NODES", "traits": "TRAITS", "provides": "PROVIDES", "hooks": "HOOKS", } diff --git a/core/schema.py b/core/schema.py index bbc69720..7f71dc3e 100644 --- a/core/schema.py +++ b/core/schema.py @@ -372,6 +372,10 @@ def validate(announcement: Dict[str, Any], source: str = "") -> ModuleSpec: raise SchemaError(f"module {announcement['id']!r}'s {name} must contain strings.") return list(value) + uses_when = announcement.get("uses_when") + if uses_when is not None: + _validate_when(uses_when, f"module {announcement['id']!r}'s uses_when", raw, "") + return ModuleSpec( nodes=validate_nodes(announcement.get("nodes"), announcement["id"]), traits=validate_traits(announcement.get("traits"), announcement["id"]), @@ -382,6 +386,7 @@ def validate(announcement: Dict[str, Any], source: str = "") -> ModuleSpec: settings=settings, requires=_ids("requires"), uses=_ids("uses"), + uses_when=dict(uses_when or {}), hooks=_ids("hooks"), after=_ids("after"), before=_ids("before"), diff --git a/core/tests/test_schema.py b/core/tests/test_schema.py index 73f9eb5e..e7143920 100644 --- a/core/tests/test_schema.py +++ b/core/tests/test_schema.py @@ -293,3 +293,22 @@ def test_the_taste_key_serves_the_store_and_every_module_reading_it_says_so(comf and re.search(r'ask\("taste_store"', p.read_text())} declared = {spec.source.split(".")[-1] for spec in routes.modules().specs.values() if "taste_store" in spec.uses} assert readers and readers <= declared, readers - declared + + +def test_a_module_that_skips_the_store_in_some_mode_says_when_it_asks(comfyui): + """`ask("taste_store")` behind `None if manual else` means the Taste key is idle in that mode: declared, so it can hide.""" + import re + from core import routes + for spec in routes.modules().specs.values(): + if "taste_store" not in spec.uses: + continue + import importlib + src = importlib.import_module(spec.source).__file__ + skips = bool(re.search(r'None if manual else [^\n]*ask\("taste_store"', open(src).read())) + assert bool(spec.uses_when) == skips, (spec.id, spec.uses_when, skips) + + +def test_uses_when_must_name_a_declared_setting(): + with pytest.raises(SchemaError, match="uses_when"): + validate(announce(uses=["taste_store"], uses_when={"nope": "x"})) + assert validate(announce(uses=["taste_store"], uses_when={"enabled": True})).to_manifest()["uses_when"] == {"enabled": True} diff --git a/modules/sampling/decisiveness/__init__.py b/modules/sampling/decisiveness/__init__.py index 1b500b65..a1a4749b 100644 --- a/modules/sampling/decisiveness/__init__.py +++ b/modules/sampling/decisiveness/__init__.py @@ -34,6 +34,7 @@ CATEGORY = "guidance" STATUS = "experimental" REQUIRES = ["temporal_latent"] USES = ["taste_store"] +USES_WHEN = {"mode": "learned"} # "My value" never asks the store SETTINGS = { "enabled": { diff --git a/modules/sampling/late_branch/__init__.py b/modules/sampling/late_branch/__init__.py index dc93806e..6c11812b 100644 --- a/modules/sampling/late_branch/__init__.py +++ b/modules/sampling/late_branch/__init__.py @@ -31,6 +31,7 @@ CATEGORY = "guidance" STATUS = "experimental" REQUIRES = ["temporal_latent", "dit_block_hooks"] USES = ["taste_store"] +USES_WHEN = {"mode": "learned"} # "My value" never asks the store # Innermost APPLY_MODEL: every other wrapper sees one guided prediction, not two calls. AFTER = ["score_slider"] diff --git a/modules/sampling/stas/__init__.py b/modules/sampling/stas/__init__.py index 9d22f981..262ce8a2 100644 --- a/modules/sampling/stas/__init__.py +++ b/modules/sampling/stas/__init__.py @@ -38,6 +38,7 @@ CATEGORY = "guidance" STATUS = "experimental" REQUIRES = ["temporal_latent", "dit_block_hooks"] USES = ["taste_store"] +USES_WHEN = {"mode": "learned"} # "My value" never asks the store SETTINGS = { "enabled": {