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:
co-authored by
Claude Sonnet 5.5
parent
79a7baa2ec
commit
1724e94c2d
@@ -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:
|
||||
|
||||
@@ -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"]}')
|
||||
|
||||
Reference in New Issue
Block a user