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:
co-authored by
Claude Opus 5.5
parent
0c923f0312
commit
83665a7234
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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()) });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, "");
|
||||
});
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user