feat: module control — switch a module off per project, and a module that fails stays off across runs until repaired or turned back on (Settings > Modules)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 02:59:57 +03:00
co-authored by Claude Sonnet 5.5
parent 79a7baa2ec
commit 1724e94c2d
22 changed files with 525 additions and 18 deletions
+19 -3
View File
@@ -33,7 +33,7 @@ import json
from comfy_api.latest import io
from ..._core import (log, patching, registry as registry_mod,
from ..._core import (control as control_mod, log, patching, registry as registry_mod,
relations as relations_mod, run as run_mod,
schema as schema_mod)
@@ -120,6 +120,11 @@ class FunPackModifierSettings(io.ComfyNode):
specs = registry_mod.current().specs
problems = []
for module_id, values in raw.items():
if module_id in control_mod.RESERVED:
said = control_mod.bad_off(values)
if said:
problems.append(said)
continue
spec = specs.get(module_id)
if spec is None:
problems.append(f"no module named {module_id!r} is installed")
@@ -152,6 +157,13 @@ class FunPackModifierSettings(io.ComfyNode):
guards_off = cls._guards_off(raw)
problems, checked = [], {}
for module_id, values in raw.items():
if module_id in control_mod.RESERVED:
said = control_mod.bad_off(values)
if said:
problems.append(said)
else:
checked[module_id] = values
continue
spec = specs.get(module_id)
if spec is None:
# Silently ignoring it is how v4 accumulated settings that meant
@@ -192,8 +204,9 @@ class FunPackLoadModifiers(io.ComfyNode):
@classmethod
def fingerprint_inputs(cls, **_):
return "|".join(f"{spec.id}={answer()}"
for spec, answer in registry_mod.current().providers(CACHE_KEY))
return "|".join([f"{spec.id}={answer()}"
for spec, answer in registry_mod.current().providers(CACHE_KEY)]
+ ["quarantine=" + control_mod.fingerprint()])
@classmethod
def execute(cls, model, settings=None) -> io.NodeOutput:
@@ -214,6 +227,7 @@ class FunPackLoadModifiers(io.ComfyNode):
offering = [spec for spec in specs if spec.provides.get(CAPABILITY)]
compatible, incompatible = traits_mod.split(offering, available)
compatible, switched = control_mod.partition(compatible, settings)
ordered, rejected = order(compatible)
patched = model.clone()
@@ -241,6 +255,7 @@ class FunPackLoadModifiers(io.ComfyNode):
"these settings are yours.")
if stripped:
notes.append(f"replaced {stripped} modifier(s) already on this model")
notes.extend(switched)
# Checked HERE, not only where the payload was made. A FUNPACK_SETTINGS
# socket is typed by a string tag, so a dict can reach this node without
@@ -270,6 +285,7 @@ class FunPackLoadModifiers(io.ComfyNode):
# Absent, and said out loud. A modifier that half-installed and
# carried on is how a run silently stops meaning what it says.
log.broke(f"{spec.id}.{CAPABILITY}", exc, "installing itself")
control_mod.start_failed(spec, exc)
notes.append(f"{spec.id}: failed to install -- {type(exc).__name__}: {exc}")
continue
if note is None:
+3 -2
View File
@@ -27,7 +27,7 @@ import torch
from comfy_api.latest import io
from ..._core import chain as chain_mod, log, patching, registry as registry_mod, run as run_mod
from ..._core import relations as relations_mod, schema as schema_mod, traits as traits_mod
from ..._core import control as control_mod, relations as relations_mod, schema as schema_mod, traits as traits_mod
# The hook points this sampler offers. A modifier asking for anything else is
# absent and said, rather than half-running.
@@ -201,6 +201,7 @@ class FunPackSampler(io.ComfyNode):
offering = [s for s in specs if s.provides.get("sampler_modifier")]
compatible, incompatible = traits_mod.split(offering, available)
compatible, switched = control_mod.partition(compatible, settings)
ordered, rejected = relations_mod.order(compatible)
guards_off = any(answer(settings or {}) for _spec, answer
@@ -218,7 +219,7 @@ class FunPackSampler(io.ComfyNode):
notes.append(f"{spec.id}: needs {', '.join(traits_mod.missing_for(spec, available))}")
for spec, why in rejected:
notes.append(f"{spec.id}: {why}")
return chain, notes
return chain, notes + switched
@classmethod
def _with_chain(cls, sampler, chain, sigmas, run):
@@ -136,11 +136,32 @@ def test_the_record_is_per_run_not_per_process(patcher):
hook(_pre_cfg_args([torch.randn(1, 4, 8, 16, 16), torch.randn(1, 4, 8, 16, 16)]))
return patched.funpack_dropped
first, second = run(), run()
from core import control
first = run()
assert control.release("sharpen") # a fault is also remembered across runs: see core/tests/test_control.py
second = run()
assert first is not second
assert "funpack.sharpen" in first and "funpack.sharpen" in second
def test_a_module_that_failed_stays_off_for_the_next_generation_until_turned_back_on(patcher):
import torch
from core import control
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
values = {"sharpen": {"enabled": True, "amount": 0.4}}
first, _ = FunPackLoadModifiers.execute(patcher, settings=values).result
first.model_options["sampler_pre_cfg_function"][0](
_pre_cfg_args([torch.randn(1, 4, 8, 16, 16), torch.randn(1, 4, 8, 16, 16)]))
assert "sharpen" in control.quarantined(__import__("core.registry", fromlist=["x"]).current().specs.values())
second, status = FunPackLoadModifiers.execute(patcher, settings=values).result
assert "sampler_pre_cfg_function" not in second.model_options
assert "sharpen: OFF" in status and "Settings ▸ Modules" in status
control.release("sharpen")
third, _ = FunPackLoadModifiers.execute(patcher, settings=values).result
assert "sampler_pre_cfg_function" in third.model_options
def test_a_hook_shape_with_no_known_neutral_result_is_named(patcher):
"""Honesty about the limit: an unguardable hook is installed and said out
loud, rather than counted as protected."""
@@ -207,6 +228,8 @@ def test_the_guards_are_on_unless_asked_otherwise(patcher):
original = video[0].clone()
out = hook(_pre_cfg_args(video)) # must not raise
assert torch.equal(out[0], original)
from core import control
control.release("sharpen") # it faulted on purpose; each case starts with it on
def test_is_on_reads_the_switch_the_same_way_everywhere():
@@ -156,3 +156,13 @@ def test_validation_catches_the_same_things_execution_would(registry, payload, e
with pytest.raises(RuntimeError):
_run(payload)
def test_the_projects_off_list_passes_through_and_is_not_mistaken_for_a_module(registry):
out, _status = _run('{"alg": {"enabled": true}, "_off": {"modules": ["sharpen"]}}')
assert out["_off"] == {"modules": ["sharpen"]} and out["alg"]["enabled"] is True
def test_a_malformed_off_list_is_refused_with_a_reason(registry):
with pytest.raises(RuntimeError, match="modules"):
_run('{"_off": ["sharpen"]}')