fix: Taste key follows each learner's real store use (USES_WHEN); Sampler saves a value typed back; pipeline requests give up instead of freezing; a failed module list recovers; a stale waiting pipeline never lands on a later project; Shot camera memory appears once ComfyUI answers; Generate waits for a pipeline going in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-05 10:42:51 +03:00
co-authored by Claude Opus 5.5
parent 0c923f0312
commit 83665a7234
13 changed files with 133 additions and 11 deletions
+1
View File
@@ -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;
+21
View File
@@ -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]);
});
+4 -1
View File
@@ -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()) });
+9 -3
View File
@@ -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",
+27 -4
View File
@@ -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();
+41 -2
View File
@@ -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, "");
});
+2
View File
@@ -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,
+1 -1
View File
@@ -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",
}
+5
View File
@@ -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"),
+19
View File
@@ -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}
@@ -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": {
+1
View File
@@ -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"]
+1
View File
@@ -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": {