Two nodes. One carries the values a person picked, as a single validated payload rather than a socket per setting -- a socket per setting means this node's shape changes whenever any module gains an option, and every saved workflow refers to sockets that moved. The other reads what announced itself, filters it by what the model actually is, orders it by declared relations, and installs each one through ComfyUI's own patcher_extension wrappers. The consequence is checked against comfy's real prepare_model_patcher and WrapperExecutor rather than against our own dict: a model that has been through this node runs every per-step modifier inside ComfyUI's own SamplerCustomAdvanced. The sampler is not consulted, so it cannot own them. Installing happens on a clone, and clone() copies the wrapper lists per key, so the model everyone else holds is untouched. Fifty runs leave zero wrappers behind. The accumulation that once degraded output across a hundred generations and survived every reset short of a restart is structurally impossible here rather than something to remember to undo. The payload is validated against the same declaration that renders the panel: an undeclared key, a wrong type, a value outside its own bounds and a bool passed as a number are all refused, and all problems are reported at once. v4 validated none of it, which is why it needed a hand-mirrored table of every default and a list of keys that no longer meant anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
6.3 KiB
Python
153 lines
6.3 KiB
Python
"""Modifiers: what the user chose, and putting it on the model.
|
|
|
|
Two nodes, because the two jobs are genuinely different. One carries the values a
|
|
person picked; the other reads what is installed and applies whatever announced
|
|
itself. Neither names a modifier, and no sampler is involved in either.
|
|
|
|
**Modifiers attach to the MODEL, not to a sampler.** They install through
|
|
ComfyUI's own `patcher_extension` wrappers on a CLONE of the patcher, which has
|
|
two consequences worth stating:
|
|
|
|
* Wire the resulting model into ComfyUI's own SamplerCustomAdvanced and every
|
|
per-step modifier still runs. The sampler's cooperation is not required, so no
|
|
sampler can own them -- which is the whole architectural claim, made physical.
|
|
* `ModelPatcher.clone()` copies the wrapper lists per key, so installing on the
|
|
clone cannot touch the model everyone else is holding. Accumulation across runs
|
|
-- the fault that once degraded output for a hundred generations and survived
|
|
every reset short of a restart -- is structurally impossible here rather than
|
|
something to remember to clean up.
|
|
"""
|
|
|
|
import json
|
|
|
|
from comfy_api.latest import io
|
|
|
|
from ..._core import log, registry as registry_mod, schema as schema_mod
|
|
|
|
CAPABILITY = "modifier"
|
|
|
|
# Namespaced so removing ours never touches a wrapper somebody else installed.
|
|
KEY_PREFIX = "funpack."
|
|
|
|
|
|
class FunPackModifierSettings(io.ComfyNode):
|
|
"""The values a person picked, on their way to the graph.
|
|
|
|
One widget, not one per setting. A socket per setting would mean this node's
|
|
shape changing every time any module gained an option, and every saved
|
|
workflow referring to sockets that had moved.
|
|
"""
|
|
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id="FunPackModifierSettings",
|
|
display_name="FunPack Modifier Settings",
|
|
category="FunPack/Sampling",
|
|
description="Settings for every FunPack modifier, as one payload.",
|
|
inputs=[
|
|
io.String.Input(
|
|
"settings", multiline=True, default="{}",
|
|
tooltip='{"module_id": {"setting": value}}. The app writes this; '
|
|
'editing it by hand is fine, and mistakes are reported.'),
|
|
],
|
|
outputs=[
|
|
io.Custom("FUNPACK_SETTINGS").Output(display_name="settings"),
|
|
io.String.Output(display_name="status"),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, settings: str) -> io.NodeOutput:
|
|
text = (settings or "").strip() or "{}"
|
|
try:
|
|
raw = json.loads(text)
|
|
except ValueError as exc:
|
|
raise RuntimeError(f"FunPack Modifier Settings: this is not valid JSON -- {exc}")
|
|
if not isinstance(raw, dict):
|
|
raise RuntimeError(
|
|
f"FunPack Modifier Settings: expected an object keyed by module id, "
|
|
f"got {type(raw).__name__}.")
|
|
|
|
specs = registry_mod.current().specs
|
|
problems, checked = [], {}
|
|
for module_id, values in raw.items():
|
|
spec = specs.get(module_id)
|
|
if spec is None:
|
|
# Silently ignoring it is how v4 accumulated settings that meant
|
|
# nothing and a list of dead keys to remember.
|
|
problems.append(f"no module named {module_id!r} is installed.")
|
|
continue
|
|
clean, said = schema_mod.check_values(spec, values)
|
|
checked[module_id] = clean
|
|
problems.extend(said)
|
|
|
|
if problems:
|
|
raise RuntimeError("FunPack Modifier Settings:\n " + "\n ".join(problems))
|
|
|
|
named = ", ".join(sorted(checked)) or "nothing set"
|
|
return io.NodeOutput(checked, f"settings for {named}")
|
|
|
|
|
|
class FunPackLoadModifiers(io.ComfyNode):
|
|
"""Whatever announced itself, filtered by this model, installed in order."""
|
|
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id="FunPackLoadModifiers",
|
|
display_name="FunPack Load Modifiers",
|
|
category="FunPack/Sampling",
|
|
description="Apply the enabled modifiers to a model. Works with any sampler.",
|
|
inputs=[
|
|
io.Model.Input("model"),
|
|
io.Custom("FUNPACK_SETTINGS").Input("settings", optional=True),
|
|
],
|
|
outputs=[
|
|
io.Model.Output(display_name="model"),
|
|
io.String.Output(display_name="status"),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, model, settings=None) -> io.NodeOutput:
|
|
from ..._core import traits as traits_mod
|
|
from core.relations import order
|
|
|
|
registry = registry_mod.current()
|
|
specs = list(registry.specs.values())
|
|
|
|
# What this model actually is, read off the model itself.
|
|
available = traits_mod.traits_of(model, specs)
|
|
|
|
offering = [spec for spec in specs if spec.provides.get(CAPABILITY)]
|
|
compatible, incompatible = traits_mod.split(offering, available)
|
|
ordered, rejected = order(compatible)
|
|
|
|
patched = model.clone()
|
|
applied, notes = [], []
|
|
|
|
for spec in ordered:
|
|
values = (settings or {}).get(spec.id) or spec.defaults()
|
|
install = spec.provides[CAPABILITY]
|
|
try:
|
|
note = install(patched, values, key=KEY_PREFIX + spec.id)
|
|
except Exception as exc: # noqa: BLE001
|
|
# Absent, and said out loud. A modifier that half-installed and
|
|
# carried on is how a run silently stops meaning what it says.
|
|
log.failed(f"{spec.id}.{CAPABILITY}", exc)
|
|
notes.append(f"{spec.id}: failed to install -- {type(exc).__name__}: {exc}")
|
|
continue
|
|
if note is None:
|
|
continue # the module decided it is off
|
|
applied.append(spec.id)
|
|
notes.append(f"{spec.id}: {note}")
|
|
|
|
for spec in incompatible:
|
|
notes.append(f"{spec.id}: needs {', '.join(traits_mod.missing_for(spec, available))}")
|
|
for spec, why in rejected:
|
|
notes.append(f"{spec.id}: {why}")
|
|
|
|
headline = f"{len(applied)} modifier(s) applied" + (f": {', '.join(applied)}" if applied else "")
|
|
return io.NodeOutput(patched, "\n".join([headline, f"model traits: {', '.join(available) or 'none read'}", *notes]))
|