diff --git a/app/ui/features/settings/models.js b/app/ui/features/settings/models.js index 8b68b31f..24a0962b 100644 --- a/app/ui/features/settings/models.js +++ b/app/ui/features/settings/models.js @@ -53,7 +53,7 @@ export const models = (app) => function mount() { if (JSON.stringify((live.inputs || {})[name]) === JSON.stringify(value)) return; // a blur that changed nothing saves nothing await ps.save({ inputs: { [slot.id]: { [name]: value } } }); const spec = specs[slot.node]; - if (spec && (spec.widgets || []).some((w) => w.shows && w.shows.input === name)) { // a choice that brings fields: what they show is what runs + if (spec && (spec.widgets || []).some((w) => (w.shows || []).some((c) => c.input === name))) { // a choice that brings fields: what they show is what runs const now = slotsNow().find((x) => x.id === slot.id); const fill = now && shownValues(now, spec); if (fill && Object.keys(fill).length) await ps.save({ inputs: { [slot.id]: fill } }); diff --git a/app/ui/features/settings/wiring.js b/app/ui/features/settings/wiring.js index 6bcf3546..98453b00 100644 --- a/app/ui/features/settings/wiring.js +++ b/app/ui/features/settings/wiring.js @@ -19,18 +19,18 @@ export function sourcesFor(slot, type, slots, specs) { } // The dropdowns' own values as the form would fill them, so a new node's revealed fields follow its default choice. -const shownParents = (slot, spec) => Object.fromEntries(((spec && spec.widgets) || []).filter((w) => w.type === "COMBO" && !(slot.inputs && slot.inputs[w.name] !== undefined)) - .map((w) => [w.name, w.default !== undefined ? w.default : (w.choices || [])[0]])); +const shownParents = (slot, spec) => Object.fromEntries(((spec && spec.widgets) || []).filter((w) => !(slot.inputs && slot.inputs[w.name] !== undefined)) + .map((w) => [w.name, w.default !== undefined ? w.default : w.type === "BOOLEAN" ? false : (w.choices || [])[0]])); /** A node that is new, or whose input was just unwired, has nothing SET on it while its form shows defaults: * these are the values to send so that what runs is what is seen. */ -/** Whether a field a dynamic dropdown's choice brings is showing: its dropdown (as set, else its default) is on one of its choices. */ -export const showing = (w, slot, spec) => { - if (!w.shows) return true; - const parent = ((spec && spec.widgets) || []).find((x) => x.name === w.shows.input); - const picked = slot.inputs && slot.inputs[w.shows.input] !== undefined ? slot.inputs[w.shows.input] : parent && (parent.default !== undefined ? parent.default : (parent.choices || [])[0]); - return w.shows.when.includes(picked); -}; +/** Whether a field is drawn: every input its `shows` names ([{input, when}]: a dynamic dropdown's choice, or a + * node's own rule) holds one of the listed values -- as set, else as the form shows it by default. */ +export const showing = (w, slot, spec) => (w.shows || []).every(({ input, when }) => { + const parent = ((spec && spec.widgets) || []).find((x) => x.name === input); + const picked = slot.inputs && slot.inputs[input] !== undefined ? slot.inputs[input] : parent && (parent.default !== undefined ? parent.default : parent.type === "BOOLEAN" ? false : (parent.choices || [])[0]); + return when.includes(picked); +}); export function shownValues(slot, spec, onlyInput) { const out = {}; diff --git a/app/ui/features/settings/wiring.test.js b/app/ui/features/settings/wiring.test.js index df17c4f2..6e18e7bb 100644 --- a/app/ui/features/settings/wiring.test.js +++ b/app/ui/features/settings/wiring.test.js @@ -21,3 +21,18 @@ test("what the form shows is sent for a slot with nothing set, and nothing alrea assert.deepEqual(shownValues({ id: "c", inputs: { steps: 4 } }, specs.C), { mode: "x", on: false }); assert.deepEqual(shownValues({ id: "c", inputs: {} }, specs.C, "steps"), { steps: 8 }); }); + +test("a field shows only in the mode it belongs to: every rule it names must hold, defaults count", async () => { + const { showing, shownValues } = await import("./wiring.js"); + const spec = { widgets: [ + { name: "sla", type: "BOOLEAN", default: false }, + { name: "sla_method", type: "COMBO", choices: ["sla", "sol-attn", "vsa"], default: "sla", shows: [{ input: "sla", when: [true] }] }, + { name: "sla_sparsity", type: "FLOAT", default: 0.9, shows: [{ input: "sla", when: [true] }, { input: "sla_method", when: ["sla", "vsa"] }] }, + { name: "sla_tau", type: "FLOAT", default: 1.3, shows: [{ input: "sla", when: [true] }, { input: "sla_method", when: ["sol-attn"] }] }, + ] }; + const drawn = (inputs) => spec.widgets.filter((w) => showing(w, { inputs }, spec)).map((w) => w.name); + assert.deepEqual(drawn({}), ["sla"]); + assert.deepEqual(drawn({ sla: true }), ["sla", "sla_method", "sla_sparsity"]); + assert.deepEqual(drawn({ sla: true, sla_method: "sol-attn" }), ["sla", "sla_method", "sla_tau"]); + assert.deepEqual(shownValues({ inputs: { sla: true, sla_method: "sol-attn" } }, spec), { sla_tau: 1.3 }); +}); diff --git a/core/widgets.py b/core/widgets.py index d726ae51..466dc8d6 100644 --- a/core/widgets.py +++ b/core/widgets.py @@ -53,6 +53,9 @@ def _one(name: str, declared: Any, required: bool) -> Dict[str, Any]: for key in KEPT: if key in options: widget[key] = options[key] + # [{input, when}]: the form draws this only while every named input holds one of its values. + if isinstance(options.get("funpack_shows"), list): + widget["shows"] = options["funpack_shows"] return widget @@ -96,7 +99,7 @@ def describe(class_type: str) -> Optional[dict]: kind, options = comfy_types.declared(declared) if comfy_types.is_combo(kind) and comfy_types.reveals(options): for dotted, decl, req, parent, keys in comfy_types.revealed(name, options): - sub = dict(_one(dotted, decl, req), shows={"input": parent, "when": keys}) + sub = dict(_one(dotted, decl, req), shows=[{"input": parent, "when": keys}]) (widgets if sub["type"] in PRIMITIVE and not sub.get("forceInput") else sockets).append(sub) outputs = list(getattr(node, "RETURN_TYPES", ()) or ()) diff --git a/modules/loaders/diffusion_model/nodes.py b/modules/loaders/diffusion_model/nodes.py index c061021b..63428334 100644 --- a/modules/loaders/diffusion_model/nodes.py +++ b/modules/loaders/diffusion_model/nodes.py @@ -31,6 +31,12 @@ def _model_file_choices(): gguf_support.gguf_names("diffusion_models") + +def shows(*conditions): + """Draw an input only while each (other input, values) holds: SLA's fields belong to one method + or engine each, and a field that does nothing in the mode picked is noise. Read by core/widgets.""" + return {"funpack_shows": [{"input": name, "when": list(values)} for name, values in conditions]} + class FunPackDiffusionModelLoader(io.ComfyNode): @classmethod def define_schema(cls) -> io.Schema: @@ -64,14 +70,14 @@ class FunPackDiffusionModelLoader(io.ComfyNode): "that is not MiniMax H3 or on a machine without Triton."), # SLA settings. Every one is validated at its default (see sla_attention); # they are here because a knob whose value was measured is still a knob. - io.Float.Input("sla_sparsity", default=sla_attention.SLA_DEFAULTS["sparsity_ratio"], + io.Float.Input("sla_sparsity", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "vsa"])), default=sla_attention.SLA_DEFAULTS["sparsity_ratio"], min=0.0, max=0.95, step=0.05, optional=True, tooltip="Fraction of key blocks skipped, when attention is " "sla_h3. 0.90 is validated; 0.85 is lightx2v's own value " "and ~15% slower. Break-even is around 0.60 -- below that " "the kernel is SLOWER than dense, so a low value is a " "loss, not a safe fallback."), - io.Combo.Input("sla_block_size", + io.Combo.Input("sla_block_size", extra_dict=shows(("sla", [True]), ("sla_method", ["sla"]), ("sla_engine", ["triton"])), options=["32", "64", "128"], default=str(sla_attention.SLA_DEFAULTS["block_size"]), optional=True, @@ -81,21 +87,21 @@ class FunPackDiffusionModelLoader(io.ComfyNode): "the same rows are 3% of a video frame. Use 128 only " "when the audio does not matter; 32 routes finer still for " "about the same time. Triton engine only."), - io.Boolean.Input("sla_protect_audio", + io.Boolean.Input("sla_protect_audio", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "sol-attn"])), default=sla_attention.SLA_DEFAULTS["protect_audio"], optional=True, tooltip="Always attend the prompt's words and every audio " "stream, whatever top-k picks. Audio is ~1% of the packed " "sequence, so plain top-k regularly drops all of it " "and the soundtrack degrades while the video still " "looks fine. Reference images stay sparse: see sla_references."), - io.Int.Input("sla_min_seq_len", + io.Int.Input("sla_min_seq_len", extra_dict=shows(("sla", [True])), default=sla_attention.SLA_DEFAULTS["min_seq_len"], min=0, max=1000000, step=1024, optional=True, tooltip="Sequences shorter than this stay dense. Guards the short " "text refiner, which must never be sparsified, and " "low-resolution runs where block selection costs more " "than it saves."), - io.Int.Input("sla_dense_last_steps", + io.Int.Input("sla_dense_last_steps", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "sol-attn"])), default=sla_attention.SLA_DEFAULTS["dense_last_steps"], min=0, max=8, optional=True, tooltip="Run the last N sampling steps at full attention. 0 " @@ -112,38 +118,38 @@ class FunPackDiffusionModelLoader(io.ComfyNode): "always holds; the speed-up is lost on layers a LoRA is applied " "to and when compute dtype is forced."), # Added later, so after everything: saved workflows can store widget values by position. - io.Combo.Input("sla_engine", options=list(sla_attention.ENGINES), + io.Combo.Input("sla_engine", extra_dict=shows(("sla", [True]), ("sla_method", ["sla"])), options=list(sla_attention.ENGINES), default=sla_attention.SLA_DEFAULTS["engine"], optional=True, tooltip="What runs the sparse calls. comfy_kitchen: ComfyUI's compiled " "int8 sol_attn (needs comfy-kitchen 0.2.32+; the Triton kernel " "runs instead where it is missing, and the status says so). " "triton: FunPack's own kernel, the only one with block size and " "stabilize motion."), - io.String.Input("sla_dense_steps", default=sla_attention.SLA_DEFAULTS["dense_steps"], + io.String.Input("sla_dense_steps", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "sol-attn"])), default=sla_attention.SLA_DEFAULTS["dense_steps"], optional=True, tooltip="Steps at full attention, counted from 0: '0' or '0,1' or " "'0-2'. The first steps set the layout and how closely the " "prompt is followed, so the first one is kept exact by " "default. Blank: none. Adds to dense last steps. Not used by vsa."), - io.Combo.Input("sla_references", options=list(sla_attention.REFERENCES), + io.Combo.Input("sla_references", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "sol-attn"])), options=list(sla_attention.REFERENCES), default=sla_attention.SLA_DEFAULTS["references"], optional=True, tooltip="Reference images, conditioning frames and the prompt's vision " "tokens. off: sparse like the video. light: each keeps its best " "15% for every query block. heavy: all of them exact, slow with " "big references. On comfy_kitchen, light picks one 15% for all " "query blocks."), - io.Boolean.Input("sla_tail", default=sla_attention.SLA_DEFAULTS["tail"], optional=True, + io.Boolean.Input("sla_tail", extra_dict=shows(("sla", [True]), ("sla_method", ["sla"]), ("sla_engine", ["comfy_kitchen"])), default=sla_attention.SLA_DEFAULTS["tail"], optional=True, tooltip="comfy_kitchen engine: the skipped blocks still count, as one " "averaged term per query block, instead of not at all. Off " "matches what the SLA turbo LoRA was trained against; untested " "on H3."), - io.Boolean.Input("sla_stabilize_motion", + io.Boolean.Input("sla_stabilize_motion", extra_dict=shows(("sla", [True]), ("sla_method", ["sla"]), ("sla_engine", ["triton"])), default=sla_attention.SLA_DEFAULTS["stabilize_motion"], optional=True, tooltip="Triton engine: each layer leans toward the blocks it picked last " "step, so near ties stop flipping and showing as a faint double " "exposure on fast motion. A fix for that one symptom; a little " "more memory."), - io.Combo.Input("sla_method", options=list(sla_attention.METHODS), + io.Combo.Input("sla_method", extra_dict=shows(("sla", [True])), options=list(sla_attention.METHODS), default=sla_attention.SLA_DEFAULTS["method"], optional=True, tooltip="How the blocks worth attending are chosen. sla: a fixed share " "(sla_sparsity), what SLA turbo LoRAs were trained against. " @@ -152,7 +158,7 @@ class FunPackDiffusionModelLoader(io.ComfyNode): "for it (the status says when the model lacks VSA's layers); run " "by ComfyUI's own Model Sparse Attention, keeping 1 - sla_sparsity " "of the cubes."), - io.Float.Input("sla_tau", default=sla_attention.SLA_DEFAULTS["tau"], min=0.0, max=4.0, + io.Float.Input("sla_tau", extra_dict=shows(("sla", [True]), ("sla_method", ["sol-attn"])), default=sla_attention.SLA_DEFAULTS["tau"], min=0.0, max=4.0, step=0.05, optional=True, tooltip="sol-attn only: higher skips more. About 1.0 keeps ~16% of key " "blocks exact, 1.5 ~7%, 2.0 ~3%."), diff --git a/modules/loaders/tests/test_sla_attention.py b/modules/loaders/tests/test_sla_attention.py index a17adda6..bc5d70bd 100644 --- a/modules/loaders/tests/test_sla_attention.py +++ b/modules/loaders/tests/test_sla_attention.py @@ -490,3 +490,15 @@ def test_sol_attn_routes_by_threshold_and_vsa_hands_over_to_comfyui(monkeypatch) assert installed and got["vsa"] is True and abs(got["topk_ratio"] - 0.1) < 1e-9 assert got["override"] is backend # dense calls still go to the chosen backend assert "no VSA layers" in note + + +def test_the_loader_draws_each_sla_field_only_in_its_mode(): + """'All values are fucked up': every SLA field was drawn whatever the method, sparsity beside + sol-attn's own threshold.""" + from modules.loaders.diffusion_model.nodes import FunPackDiffusionModelLoader as L + rules = {i.id: {c["input"]: c["when"] for c in (i.extra_dict or {}).get("funpack_shows", [])} + for i in L.define_schema().inputs} + assert rules["sla_sparsity"]["sla_method"] == ["sla", "vsa"] and rules["sla_tau"]["sla_method"] == ["sol-attn"] + assert rules["sla_block_size"]["sla_engine"] == ["triton"] and rules["sla_tail"]["sla_engine"] == ["comfy_kitchen"] + assert all(r.get("sla") == [True] for name, r in rules.items() if name.startswith("sla_")) + assert rules["sla"] == {} and rules["int8_convrot"] == {}