Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
320 lines
15 KiB
Python
320 lines
15 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.
|
|
* A module never gets the real patcher. It gets one that guards every hook it
|
|
installs, so a modifier that raises mid-sampling is dropped for the rest of
|
|
that run instead of ending it. Catching a failure to INSTALL was never the
|
|
hard part: the one that costs a rental is the hook that works at step 0 and
|
|
raises at step 3, after the GPU time is spent.
|
|
|
|
That second point protects the ORIGINAL, and on its own it does not stop this
|
|
node's own output being fed back in. `add_wrapper_with_key` APPENDS to a list
|
|
under the key, and a clone carries that list, so running a model through here
|
|
twice -- two passes, a flattened subgraph, anything that chains -- installed the
|
|
same wrapper twice and ran it twice per step at double strength, reporting
|
|
"1 modifier applied" both times. So every install is preceded by stripping our
|
|
own keys: tag, then strip, which is the discipline the leaked-hook fault taught
|
|
this project the first time. Foreign keys are never touched.
|
|
"""
|
|
|
|
import json
|
|
|
|
from comfy_api.latest import io
|
|
|
|
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)
|
|
|
|
CAPABILITY = "modifier"
|
|
|
|
# A module whose install depends on something outside the node's inputs (a switch in Settings)
|
|
# answers this with a value that changes when that does: ComfyUI re-runs this node, and the
|
|
# modifier is installed or removed, instead of the cached model being handed back.
|
|
CACHE_KEY = "cache_key"
|
|
|
|
# Anything answering this decides whether the foundation's guards step aside.
|
|
GUARDS_OFF = "guards_off"
|
|
|
|
# Namespaced so removing ours never touches a wrapper somebody else installed.
|
|
KEY_PREFIX = "funui."
|
|
|
|
|
|
class FunUIModifierSettings(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="FunUIModifierSettings",
|
|
display_name="FunUI Modifier Settings",
|
|
category="FunUI/Sampling",
|
|
description="Settings for every FunUI 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("FUNUI_SETTINGS").Output(display_name="settings"),
|
|
io.String.Output(display_name="status"),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def validate_inputs(cls, settings: str = "{}"):
|
|
"""Checked at QUEUE time, not at execution time.
|
|
|
|
ComfyUI runs this inside validate_prompt, before any node executes. That
|
|
matters on a rented GPU: without it a typo'd module id was found only
|
|
after the checkpoint had already loaded, which is the same "cost paid
|
|
before the guard fires" this project builds guards to avoid -- just one
|
|
layer up from sampling.
|
|
|
|
The payload is an opaque string to ComfyUI's own validator, so this is
|
|
the only place it can be understood before the queue accepts it.
|
|
"""
|
|
problems = cls._problems(settings)
|
|
if not problems:
|
|
return True
|
|
# With the guards off a bad value is the user's business, so the queue
|
|
# accepts it and the warning is said at execution instead.
|
|
import json as _json
|
|
try:
|
|
raw = _json.loads((settings or "").strip() or "{}")
|
|
except ValueError:
|
|
raw = {}
|
|
if cls._guards_off(raw):
|
|
return True
|
|
return "FunUI Modifier Settings: " + "; ".join(problems)
|
|
|
|
@classmethod
|
|
def _problems(cls, settings: str):
|
|
"""Everything wrong with this payload, or an empty list."""
|
|
import json as _json
|
|
text = (settings or "").strip() or "{}"
|
|
try:
|
|
raw = _json.loads(text)
|
|
except ValueError as exc:
|
|
return [f"this is not valid JSON -- {exc}"]
|
|
if not isinstance(raw, dict):
|
|
return [f"expected an object keyed by module id, got {type(raw).__name__}"]
|
|
|
|
specs = registry_mod.current().specs
|
|
problems = []
|
|
skip = control_mod.skipped(raw) # off or quarantined: its values are not read, so not checked
|
|
for module_id, values in raw.items():
|
|
if module_id in skip:
|
|
continue
|
|
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:
|
|
continue # named in saved settings, not installed: execute() says so (once) and runs without it
|
|
_clean, said = schema_mod.check_values(spec, values)
|
|
problems.extend(said)
|
|
return problems
|
|
|
|
@classmethod
|
|
def _guards_off(cls, raw) -> bool:
|
|
"""Asked by capability, so nothing here names the module that answers."""
|
|
if not isinstance(raw, dict):
|
|
return False
|
|
return any(answer(raw) for _spec, answer
|
|
in registry_mod.current().providers(GUARDS_OFF))
|
|
|
|
@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"FunUI Modifier Settings: this is not valid JSON -- {exc}")
|
|
if not isinstance(raw, dict):
|
|
raise RuntimeError(
|
|
f"FunUI Modifier Settings: expected an object keyed by module id, "
|
|
f"got {type(raw).__name__}.")
|
|
|
|
specs = registry_mod.current().specs
|
|
guards_off = cls._guards_off(raw)
|
|
problems, checked = [], {}
|
|
skip = control_mod.skipped(raw)
|
|
for module_id, values in raw.items():
|
|
if module_id in skip:
|
|
continue
|
|
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:
|
|
# Saved settings outlive modules (removed, renamed, or one that failed to load). Refusing the run for a
|
|
# name nobody can act on would break every project that ever used it, so it is skipped -- out loud.
|
|
log.once(f"stale_module:{module_id}", log.WARNING, "FunUI Modifier Settings",
|
|
f"saved settings for {module_id!r} ignored: no module with that id is loaded")
|
|
continue
|
|
clean, said = schema_mod.check_values(spec, values, keep_bad=guards_off)
|
|
checked[module_id] = clean
|
|
problems.extend(said)
|
|
|
|
# Full control reaches this node too: it is in the payload being checked.
|
|
schema_mod.refuse_or_warn(problems, "FunUI Modifier Settings",
|
|
guards_off=cls._guards_off(raw))
|
|
|
|
named = ", ".join(sorted(checked)) or "nothing set"
|
|
return io.NodeOutput(checked, f"settings for {named}")
|
|
|
|
|
|
class FunUILoadModifiers(io.ComfyNode):
|
|
"""Whatever announced itself, filtered by this model, installed in order."""
|
|
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id="FunUILoadModifiers",
|
|
display_name="FunUI Load Modifiers",
|
|
category="FunUI/Sampling",
|
|
description="Apply the enabled modifiers to a model. Works with any sampler.",
|
|
inputs=[
|
|
io.Model.Input("model"),
|
|
io.Custom("FUNUI_SETTINGS").Input("settings", optional=True),
|
|
],
|
|
outputs=[
|
|
io.Model.Output(display_name="model"),
|
|
io.String.Output(display_name="status"),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def fingerprint_inputs(cls, **_):
|
|
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:
|
|
from ..._core import traits as traits_mod
|
|
order = relations_mod.order
|
|
|
|
if settings is not None and not isinstance(settings, dict):
|
|
raise RuntimeError(
|
|
f"FunUI Load Modifiers: settings must be an object keyed by module "
|
|
f"id, got {type(settings).__name__}. Wire FunUI Modifier Settings "
|
|
f"into this input.")
|
|
|
|
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)
|
|
compatible, switched = control_mod.partition(compatible, settings)
|
|
ordered, rejected = order(compatible)
|
|
|
|
patched = model.clone()
|
|
stripped = patching.strip(patched, KEY_PREFIX)
|
|
applied, notes = [], []
|
|
|
|
# This node does NOT start the run. ComfyUI caches a node whose inputs
|
|
# have not changed, and only the seed changes between most generations --
|
|
# so this executes once and every later run would report under the first
|
|
# one's id. The sampler marks the run instead: it is what generates, and
|
|
# it re-executes whenever anything about the generation differs.
|
|
dropped = patching.Dropped()
|
|
patched.funui_dropped = dropped
|
|
patched.model_options["funui_dropped"] = dropped # stock nodes' clone() keeps model_options, not attributes
|
|
|
|
# Asked for by capability, so nothing here names the module that answers
|
|
# and a build without it installed has nobody saying yes -- which reads
|
|
# as guarded, the safe direction.
|
|
full_control = any(answer(settings)
|
|
for _spec, answer in registry.providers(GUARDS_OFF))
|
|
# Read before the values are checked, because it changes what checking
|
|
# them DOES: refuse, or let it through and say so.
|
|
if full_control:
|
|
notes.append("full control is ON: modifiers are not guarded, and a "
|
|
"failing one will end the run. Any consequences of "
|
|
"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 FUNUI_SETTINGS
|
|
# socket is typed by a string tag, so a dict can reach this node without
|
|
# ever passing through FunUIModifierSettings -- a raw API prompt, or a
|
|
# second producer of that type. Validating only at the producer is a
|
|
# guard on one of two doors, and the one left open let NaN through:
|
|
# `sigma > nan` is False at every step, so a modifier became a permanent
|
|
# no-op while reporting that it had been applied.
|
|
problems = []
|
|
chosen = {}
|
|
known = registry_mod.current().specs
|
|
for module_id in (settings or {}):
|
|
if module_id not in known and module_id not in control_mod.RESERVED:
|
|
log.once(f"stale_module_load:{module_id}", log.WARNING, "FunUI Load Modifiers",
|
|
f"saved settings for {module_id!r} ignored: no module with that id is loaded")
|
|
for spec in ordered:
|
|
values, said = schema_mod.check_values(
|
|
spec, (settings or {}).get(spec.id), keep_bad=full_control)
|
|
chosen[spec.id] = values
|
|
problems.extend(said)
|
|
schema_mod.refuse_or_warn(problems, "FunUI Load Modifiers",
|
|
guards_off=full_control)
|
|
|
|
for spec in ordered:
|
|
values = chosen[spec.id]
|
|
install = spec.provides[CAPABILITY]
|
|
key = KEY_PREFIX + spec.id
|
|
guarded = patching.GuardedPatcher(patched, key, dropped, guarding=not full_control)
|
|
try:
|
|
note = install(guarded, values, key=key)
|
|
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.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:
|
|
continue # the module decided it is off
|
|
applied.append(spec.id)
|
|
notes.append(f"{spec.id}: {note}")
|
|
for name in guarded.unguarded:
|
|
# Named rather than assumed safe: this hook shape has no known
|
|
# neutral result, so a failure inside it can still end the run.
|
|
notes.append(f"{spec.id}: {name} could not be guarded")
|
|
|
|
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]))
|