Files
imi 639c67ba1a feat: add wildcard expansion and improve autocomplete UX
- add A1111-style __wildcard__ expansion for prompt and negative nodes
- support nested wildcards and dynamic prompt syntax inside wildcard files
- add wildcard autocomplete with folder/file suggestions and content drill-down
- improve wildcard popup rendering, limits, and keep-open behavior
- normalize spacing after optional wildcard/dynamic expansions
- make autocomplete frequency ranking relevance-aware with recency weighting
- fix wildcard usage stars so selection history affects ordering
- update README, TODO, and CHANGELOG to document the new behavior
2026-04-22 00:53:38 +02:00

265 lines
7.9 KiB
Python

"""
Classic prompt tokenization helpers.
These helpers let the node opt into two ReForge/A1111-style behaviors without
replacing the default Comfy tokenization path:
1. Classic attention parsing (`[]` de-emphasis, escaped brackets, BREAK markers)
2. Classic chunking (comma backtrack instead of pure 75-token rollover)
"""
import re
from comfy import sd1_clip as comfy_sd1_clip
_RE_ATTENTION = re.compile(
r"""
\\\(|
\\\)|
\\\[|
\\]|
\\\\|
\\|
\(|
\[|
:\s*([+-]?[.\d]+)\s*\)|
\)|
]|
[^\\()\[\]:]+|
:
""",
re.X,
)
_RE_BREAK = re.compile(r"\s*\bBREAK\b\s*", re.S)
def _parse_classic_attention(text):
res = []
round_brackets = []
square_brackets = []
round_bracket_multiplier = 1.1
square_bracket_multiplier = 1 / 1.1
def multiply_range(start_position, multiplier):
for p in range(start_position, len(res)):
res[p][1] *= multiplier
for match in _RE_ATTENTION.finditer(text):
token_text = match.group(0)
weight = match.group(1)
if token_text.startswith("\\"):
res.append([token_text[1:], 1.0])
elif token_text == "(":
round_brackets.append(len(res))
elif token_text == "[":
square_brackets.append(len(res))
elif weight is not None and round_brackets:
multiply_range(round_brackets.pop(), float(weight))
elif token_text == ")" and round_brackets:
multiply_range(round_brackets.pop(), round_bracket_multiplier)
elif token_text == "]" and square_brackets:
multiply_range(square_brackets.pop(), square_bracket_multiplier)
else:
parts = re.split(_RE_BREAK, token_text)
for i, part in enumerate(parts):
if i > 0:
res.append(["BREAK", -1.0])
if part:
res.append([part, 1.0])
for pos in round_brackets:
multiply_range(pos, round_bracket_multiplier)
for pos in square_brackets:
multiply_range(pos, square_bracket_multiplier)
if len(res) == 0:
return [("", 1.0)]
merged = []
for part_text, weight in res:
if (
merged
and merged[-1][1] == weight
and merged[-1][0] != "BREAK"
and part_text != "BREAK"
):
merged[-1][0] += part_text
else:
merged.append([part_text, weight])
return [(part_text, weight) for part_text, weight in merged if part_text or weight < 0]
def _parse_default_attention(text):
escaped = comfy_sd1_clip.escape_important(text)
parsed = comfy_sd1_clip.token_weights(escaped, 1.0)
out = []
for part_text, weight in parsed:
unescaped = comfy_sd1_clip.unescape_important(part_text)
pieces = re.split(_RE_BREAK, unescaped)
for i, piece in enumerate(pieces):
if i > 0:
out.append(("BREAK", -1.0))
if piece:
out.append((piece, weight))
if not out:
return [("", 1.0)]
return out
def _get_subtokenizers(tokenizer):
subtokenizers = {}
for name in dir(tokenizer):
if not name.startswith("clip_"):
continue
value = getattr(tokenizer, name)
suffix = name.split("clip_", 1)[1]
subtokenizers[suffix] = value
if subtokenizers:
return subtokenizers
# Fallback for tokenizers that expose a single subtokenizer directly.
if hasattr(tokenizer, "tokenize_with_weights"):
return {"l": tokenizer}
raise RuntimeError("Unsupported tokenizer layout for classic prompt tokenization.")
def _segment_to_tokens(subtokenizer, text, weight):
embedding_identifier = subtokenizer.embedding_identifier
split = re.split(r" {0}|\n{0}".format(re.escape(embedding_identifier)), text)
to_tokenize = [split[0]]
for i in range(1, len(split)):
to_tokenize.append("{}{}".format(embedding_identifier, split[i]))
flat_tokens = []
for piece in [x for x in to_tokenize if x != ""]:
if piece.startswith(embedding_identifier) and subtokenizer.embedding_directory is not None:
embedding_name = piece[len(embedding_identifier) :].strip("\n")
embed, leftover = subtokenizer._try_get_embedding(embedding_name)
if embed is not None:
if len(embed.shape) == 1:
flat_tokens.append((embed, weight))
else:
for row in range(embed.shape[0]):
flat_tokens.append((embed[row], weight))
if leftover:
piece = leftover
else:
continue
end = -1 if subtokenizer.tokenizer_adds_end_token else 999999999999
token_ids = subtokenizer.tokenizer(piece)["input_ids"][
subtokenizer.tokens_start : end
]
for token in token_ids:
flat_tokens.append((token, weight))
return flat_tokens
def _find_last_comma(tokens, comma_token):
if comma_token is None:
return -1
for index in range(len(tokens) - 1, -1, -1):
token = tokens[index][0]
if isinstance(token, int) and token == comma_token:
return index
return -1
def _pack_tokens(subtokenizer, parsed_segments, use_classic_chunking):
has_start = subtokenizer.start_token is not None
has_end = subtokenizer.end_token is not None
chunk_length = subtokenizer.max_length - int(has_start) - int(has_end)
comma_token = None
if use_classic_chunking:
comma_token = subtokenizer.tokenizer.get_vocab().get(",</w>", None)
batches = []
current = []
last_comma = -1
def flush(force=False):
nonlocal current
nonlocal last_comma
if not current and not force:
return
batch = []
if has_start:
batch.append((subtokenizer.start_token, 1.0))
batch.extend(current)
if has_end:
batch.append((subtokenizer.end_token, 1.0))
while len(batch) < subtokenizer.max_length:
batch.append((subtokenizer.pad_token, 1.0))
batches.append(batch)
current = []
last_comma = -1
for part_text, weight in parsed_segments:
if part_text == "BREAK" and weight < 0:
flush(force=False)
continue
flat_tokens = _segment_to_tokens(subtokenizer, part_text, weight)
position = 0
while position < len(flat_tokens):
if len(current) == chunk_length:
flush(force=False)
token_pair = flat_tokens[position]
token_value = token_pair[0]
if use_classic_chunking and isinstance(token_value, int):
if token_value == comma_token:
last_comma = len(current)
elif (
len(current) == chunk_length
and last_comma != -1
and len(current) - last_comma <= 20
):
break_location = last_comma + 1
reloc = current[break_location:]
current = current[:break_location]
flush(force=False)
current = reloc
last_comma = _find_last_comma(current, comma_token)
continue
if len(current) == chunk_length:
flush(force=False)
current.append(token_pair)
position += 1
flush(force=not batches)
return batches
def tokenize_prompt(clip, text, use_classic_attention=False, use_classic_chunking=False):
"""
Build token-weight batches matching Comfy's expected tokenizer output shape.
"""
parsed_segments = (
_parse_classic_attention(text)
if use_classic_attention
else _parse_default_attention(text)
)
tokens = {}
for key, subtokenizer in _get_subtokenizers(clip.tokenizer).items():
tokens[key] = _pack_tokens(
subtokenizer, parsed_segments, use_classic_chunking=use_classic_chunking
)
return tokens