Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
177 lines
8.3 KiB
Python
177 lines
8.3 KiB
Python
"""The two markup nodes. See the module docstring for the shape."""
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
from comfy_api.latest import io
|
|
|
|
from ..._core import control as control_mod, dit_hooks, log, patching, registry as registry_mod, schema as schema_mod
|
|
from . import markup as mk
|
|
|
|
MARKUP = io.Custom("FUNUI_MARKUP")
|
|
KEY = "funui_markup.prompt"
|
|
PHRASES = "funui_markup_phrases" # the transformer_options key the phrase probe reads
|
|
SIMUL = "funui_simultaneous" # ... and the one Step merging reads: one whole conditioning per option, not yet refined by the model
|
|
|
|
|
|
def _ask(capability, *args):
|
|
"""The first model module with an answer, or None."""
|
|
for _spec, provider in registry_mod.current().providers(capability):
|
|
got = provider(*args)
|
|
if got is not None:
|
|
return got
|
|
return None
|
|
|
|
|
|
def _say(message):
|
|
log.once(f"prompt_markup:{message}", log.ALERT, "FunUI Prompt Markup", message)
|
|
|
|
|
|
def _alternatives(settings):
|
|
"""-> (how the main prompt reads an [A|B], whether A and B are also read separately), from Alternating prompts."""
|
|
spec = registry_mod.current().specs.get("prompt_alternatives")
|
|
if spec is None or control_mod.all_off(settings):
|
|
return "while", False
|
|
clean, _problems = schema_mod.check_values(spec, (settings or {}).get("prompt_alternatives"))
|
|
mode = clean.get("main")
|
|
return (mode if mode in mk.MAIN_MODES else "while"), bool(clean.get("separate"))
|
|
|
|
|
|
class FunUIPromptMarkup(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id="FunUIPromptMarkup",
|
|
display_name="FunUI Prompt Markup",
|
|
category="FunUI/Conditioning",
|
|
description="Take (word:1.5) and [a|b] out of a prompt, so the encoder sees clean text "
|
|
"and the markup can be applied properly.",
|
|
inputs=[io.String.Input("text", multiline=True, default=""),
|
|
io.Custom("FUNUI_SETTINGS").Input("settings", optional=True)],
|
|
outputs=[io.String.Output(display_name="clean"),
|
|
MARKUP.Output(display_name="markup"),
|
|
io.String.Output(display_name="status")],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, text: str, settings=None) -> io.NodeOutput:
|
|
main, separate = _alternatives(settings)
|
|
parsed = mk.parse(text or "", main=main)
|
|
parsed["separate"] = separate
|
|
n = {"weighted": len(parsed["weighted"]), "alternative": len(parsed["variants"])}
|
|
status = (", ".join(f"{v} {k}" for k, v in n.items() if v) or "no markup")
|
|
return io.NodeOutput(parsed["clean"], parsed, status)
|
|
|
|
|
|
def _variant_rows(rows, clip, tokenizer, texts):
|
|
"""The prompt rows of each option's own prompt, brought to the length of `rows` (the prompt as encoded) --
|
|
[1, n, D] each, or None when one of them cannot be placed."""
|
|
start, end = rows
|
|
out = []
|
|
for text in texts:
|
|
encoded = clip.encode_from_tokens_scheduled(clip.tokenize(text))
|
|
cond, meta = encoded[0][0], encoded[0][1]
|
|
n = len(tokenizer(text, add_special_tokens=False, return_offsets_mapping=True)["offset_mapping"])
|
|
placed = _ask("prompt_rows", meta, int(cond.shape[1]), n)
|
|
if placed is None:
|
|
return None
|
|
got = cond[:, placed[0]:placed[1], :]
|
|
if got.shape[1] != end - start:
|
|
# Linear along the token axis: a smoothing, not a word alignment.
|
|
got = F.interpolate(got.float().transpose(1, 2), size=end - start, mode="linear",
|
|
align_corners=True).transpose(1, 2).to(got.dtype)
|
|
out.append(got)
|
|
return out
|
|
|
|
|
|
class FunUIApplyPromptMarkup(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id="FunUIApplyPromptMarkup",
|
|
display_name="FunUI Apply Prompt Markup",
|
|
category="FunUI/Conditioning",
|
|
description="Apply the markup FunUI Prompt Markup took out: weights on the model, and one "
|
|
"prompt per [a|b] option for Step merging to use.",
|
|
inputs=[io.Model.Input("model"), io.Clip.Input("clip"),
|
|
io.Conditioning.Input("positive"), io.Latent.Input("latent"),
|
|
MARKUP.Input("markup")],
|
|
outputs=[io.Model.Output(display_name="model"),
|
|
io.Conditioning.Output(display_name="positive"),
|
|
io.String.Output(display_name="status")],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, model, clip, positive, latent, markup) -> io.NodeOutput:
|
|
patched = patching.clone(model)
|
|
patching.strip(patched, "funui_markup.")
|
|
dit_hooks.publish(patched, SIMUL, None)
|
|
weighted = (markup or {}).get("weighted")
|
|
variants = (markup or {}).get("variants") if (markup or {}).get("separate") else None
|
|
if not (weighted or variants):
|
|
return io.NodeOutput(patched, positive, "no markup")
|
|
|
|
def refuse(why):
|
|
_say(f"markup NOT applied: {why}. The prompt still went in, without it.")
|
|
return io.NodeOutput(patched, positive, f"not applied: {why}")
|
|
|
|
tokenizer = _ask("text_tokenizer", clip)
|
|
if tokenizer is None:
|
|
return refuse("this text encoder is not one a model module can place words in")
|
|
clean = markup["clean"]
|
|
offsets = list(tokenizer(clean, add_special_tokens=False,
|
|
return_offsets_mapping=True)["offset_mapping"])
|
|
cond, meta = positive[0][0], positive[0][1]
|
|
cond_len = int(cond.shape[1])
|
|
rows = _ask("prompt_rows", meta, cond_len, len(offsets))
|
|
if rows is None:
|
|
return refuse("the encoded prompt does not match this text -- was the clean "
|
|
"text from Prompt Markup what got encoded?")
|
|
said = []
|
|
if markup.get("extra"):
|
|
_say(f"[a|b] takes {mk.MAX_OPTIONS} options; the {markup['extra']} after them are ignored")
|
|
if variants:
|
|
got = _variant_rows(rows, clip, tokenizer, variants)
|
|
if got is None:
|
|
_say("a [a|b] option's prompt could not be placed; the options are off")
|
|
else:
|
|
whole = []
|
|
for rows_k in got: # the full conditioning, as encoded, with this option's prompt rows
|
|
raw = cond.clone()
|
|
raw[:, rows[0]:rows[1]] = rows_k.to(device=raw.device, dtype=raw.dtype)
|
|
whole.append(raw)
|
|
dit_hooks.publish(patched, SIMUL, {"options": whole, "cond_len": cond_len})
|
|
said.append(f"{len(got)} simultaneous option(s)")
|
|
w_spans = mk.token_spans(offsets, weighted or [])
|
|
if w_spans:
|
|
cls._install(patched, w_spans, rows[0], cond_len)
|
|
# Where the phrases sit, for anything that wants to look at them (the phrase probe).
|
|
dit_hooks.publish(patched, PHRASES, {
|
|
"base": rows[0], "cond_len": cond_len, "spans": [(a, b) for a, b, *_ in w_spans]})
|
|
said.append(f"{len(w_spans)} weight(s)")
|
|
return io.NodeOutput(patched, positive, ", ".join(said) or "nothing placeable")
|
|
|
|
@staticmethod
|
|
def _install(patcher, w_spans, base, cond_len):
|
|
cache = {}
|
|
|
|
def override(func, q, k, v, heads, *args, mask=None, **kwargs):
|
|
def run(m):
|
|
return func(q, k, v, heads, *args, mask=m, **kwargs)
|
|
|
|
# Only the packed self-attention: the prompt plus the streams after it.
|
|
# A sequence exactly as long as the prompt is the text-only token
|
|
# refiner, which must never see the bias.
|
|
if k.ndim != 4 or q.shape[2] != k.shape[2] or k.shape[2] <= cond_len:
|
|
return run(mask)
|
|
try:
|
|
key = (int(k.shape[2]), k.device, k.dtype)
|
|
if key not in cache:
|
|
cache[key] = mk.key_bias(w_spans, base, *key)
|
|
bias = cache[key]
|
|
return run(mask if bias is None else (bias if mask is None else mask + bias))
|
|
except Exception as exc: # noqa: BLE001 -- markup never costs the step
|
|
_say(f"markup failed inside attention and was dropped ({exc})")
|
|
return run(mask)
|
|
|
|
dit_hooks.add_attention_override(patcher, KEY, override)
|