Files
Enferlain-ComfyUI-A1111-cond/parser.py
T
2026-01-11 07:14:34 +01:00

550 lines
17 KiB
Python

"""
A1111 Prompt Parser (Recursive)
Uses Lark grammar parser like A1111 for proper nested syntax support.
Supported Syntax:
- Emphasis: (text:1.2), (text), [text]
- Scheduling: [from:to:when], [add:when], [remove::when]
- Alternation: [A|B|C]
- BREAK: Splits prompt into isolated chunks
Step vs Percentage:
- If 'when' has a decimal point (e.g., 0.5), it's a percentage
- If 'when' is an integer (e.g., 10), it's a step count (requires steps input)
"""
import re
from dataclasses import dataclass, field
from typing import List, Optional, Tuple, Dict
# Try to import lark, fall back to simple parsing if not available
try:
import lark
HAS_LARK = True
except ImportError:
HAS_LARK = False
@dataclass
class WeightedPart:
"""Represents a parsed prompt segment with weight and timing info."""
text: str
weight: float = 1.0
start_percent: float = 0.0
end_percent: float = 1.0
is_alternating: bool = False
alternating_options: List[str] = field(default_factory=list)
def __str__(self) -> str:
"""Return the text content for string conversion."""
return self.text
# A1111-style Lark grammar
GRAMMAR = r"""
!start: (prompt | /[][():]/+)*
prompt: (emphasized | scheduled | alternate | plain | WHITESPACE)*
!emphasized: "(" prompt ")"
| "(" prompt ":" prompt ")"
| "[" prompt "]"
scheduled: "[" [prompt ":"] prompt ":" [WHITESPACE] NUMBER [WHITESPACE] "]"
alternate: "[" prompt ("|" [prompt])+ "]"
WHITESPACE: /\s+/
plain: /([^\\\\[\]():|]|\\.)+/
%import common.SIGNED_NUMBER -> NUMBER
"""
def parse_a1111_prompt(
prompt: str, steps: Optional[int] = None
) -> List[List[WeightedPart]]:
"""
Main entry point.
Args:
prompt: The prompt string to parse
steps: Total sampling steps (needed for step-based scheduling)
Returns: List[List[WeightedPart]]
Outer list = BREAK chunks
Inner list = Parts with timing/weight info
"""
# Split by BREAK first
break_chunks = re.split(r"\s*\bBREAK\b\s*", prompt)
if HAS_LARK:
return [_parse_chunk_lark(chunk, steps) for chunk in break_chunks]
else:
return [_parse_chunk_simple(chunk, steps) for chunk in break_chunks]
def get_prompt_schedule(prompt: str, steps: int) -> List[Tuple[int, str]]:
"""
A1111-style prompt schedule generation.
Returns list of (end_at_step, prompt_text) tuples representing
when each prompt variant should be used.
This properly handles nested alternation and scheduling.
"""
if not HAS_LARK:
# Fallback for simple prompts
return [(steps, prompt)]
# Split by BREAK first, process each chunk, then rejoin
break_chunks = re.split(r"\s*\bBREAK\b\s*", prompt)
try:
parser = lark.Lark(GRAMMAR)
trees = [parser.parse(chunk) for chunk in break_chunks]
except lark.exceptions.LarkError:
return [(steps, prompt)]
# Collect all relevant steps from all chunks
all_steps = _collect_steps(steps, trees)
# Generate prompt text for each step
schedule = []
for step in all_steps:
chunk_texts = []
for tree in trees:
text = _at_step(step, steps, tree)
if text.strip():
chunk_texts.append(text)
full_prompt = " BREAK ".join(chunk_texts)
schedule.append((step, full_prompt))
return schedule
def _collect_steps(steps: int, trees) -> List[int]:
"""Collect all steps where the prompt changes."""
result = {steps} # Always include final step
class CollectSteps(lark.Visitor):
def scheduled(self, tree):
s = str(tree.children[-2])
v = float(s)
if "." in s:
# Percentage
step_num = int(v * steps)
else:
# Step count
step_num = int(v)
step_num = max(1, min(steps, step_num))
if step_num >= 1:
result.add(step_num)
def alternate(self, tree):
# Alternation changes every step
result.update(range(1, steps + 1))
for tree in trees:
CollectSteps().visit(tree)
return sorted(result)
def _at_step(step: int, total_steps: int, tree) -> str:
"""
Generate the prompt text for a specific step.
This follows A1111's approach of walking the tree and picking
the correct option at each node based on the current step.
"""
class AtStep(lark.Transformer):
def scheduled(self, args):
# A1111 pattern: before, after, _, when, _ = args
# Use positional access like A1111
before = args[0] if len(args) > 0 else None
after = args[1] if len(args) > 1 else None
when_token = args[3] if len(args) > 3 else None
# Fallback: find NUMBER token if not at expected position
if when_token is None or not (
isinstance(when_token, lark.Token) and when_token.type == "NUMBER"
):
for arg in args:
if isinstance(arg, lark.Token) and arg.type == "NUMBER":
when_token = arg
break
if when_token is None:
for a in args:
if a is not None:
yield a
return
when_str = str(when_token)
if "." in when_str:
when_step = int(float(when_str) * total_steps)
else:
when_step = int(float(when_str))
# A1111: yield before if step <= when, else yield after
if step <= when_step:
if before is not None:
yield before
else:
if after is not None:
yield after
def alternate(self, args):
# Filter out empty/None
options = [a for a in args if a]
if not options:
yield ""
return
idx = (step - 1) % len(options)
yield options[idx]
def emphasized(self, args):
# Keep emphasis syntax in output
if len(args) == 1:
for a in args:
yield a
elif len(args) >= 2:
# (text:weight) - preserve weight
yield "("
yield from self._flatten_arg(args[0])
yield ":"
yield from self._flatten_arg(args[1])
yield ")"
else:
for a in args:
yield a
def _flatten_arg(self, arg):
if isinstance(arg, str):
yield arg
elif isinstance(arg, lark.Token):
yield str(arg)
elif hasattr(arg, "__iter__"):
for item in arg:
yield from self._flatten_arg(item)
elif arg is not None:
yield str(arg)
def plain(self, args):
yield args[0].value if args else ""
def start(self, args):
def flatten(x):
if isinstance(x, str):
yield x
elif isinstance(x, lark.Token):
yield str(x)
elif x is None or x == ():
pass
elif hasattr(x, "__iter__"):
for item in x:
yield from flatten(item)
else:
yield str(x)
return "".join(flatten(args))
def __default__(self, data, children, meta):
for child in children:
yield child
return AtStep().transform(tree)
def _parse_chunk_lark(chunk: str, steps: Optional[int]) -> List[WeightedPart]:
"""Parse a single chunk using Lark grammar."""
try:
parser = lark.Lark(GRAMMAR)
tree = parser.parse(chunk)
return _extract_parts_from_tree(tree, steps)
except lark.exceptions.LarkError:
# Fallback to simple parsing on error
return _parse_chunk_simple(chunk, steps)
def _convert_when_to_percent(when_str: str, steps: Optional[int]) -> float:
"""
Convert a 'when' value to a percentage.
- If has decimal point: treat as percentage (0.5 = 50%)
- If integer >= 1: treat as step count, convert using steps param
"""
val = float(when_str)
if "." in when_str:
# Percentage
return max(0.0, min(1.0, val))
else:
# Step count
if steps is not None and steps > 0:
return max(0.0, min(1.0, val / steps))
else:
# No steps provided, treat large values as percentage anyway
if val >= 1.0:
return 1.0 # Can't convert without steps
return val
def _extract_text(item) -> str:
"""Recursively extract text from WeightedPart or nested structures."""
if isinstance(item, WeightedPart):
if item.is_alternating and item.alternating_options:
# For alternating, return options joined (will be handled per-step elsewhere)
return "|".join(item.alternating_options)
return item.text
elif isinstance(item, list):
return "".join(_extract_text(x) for x in item)
elif HAS_LARK and isinstance(item, lark.Token):
return str(item)
elif item is None:
return ""
else:
return str(item)
def _extract_parts_from_tree(tree, steps: Optional[int]) -> List[WeightedPart]:
"""Extract WeightedParts from parsed Lark tree."""
parts = []
class PartExtractor(lark.Transformer):
def plain(self, args):
text = args[0].value if args else ""
return WeightedPart(text=text, weight=1.0)
def emphasized(self, args):
if len(args) == 1:
# Just (text) or [text]
inner = args[0]
if isinstance(inner, WeightedPart):
inner.weight *= 1.1
return inner
return WeightedPart(text=_extract_text(inner), weight=1.1)
elif len(args) == 2:
# (text:weight)
inner, weight_part = args
try:
w = float(_extract_text(weight_part))
except:
w = 1.0
if isinstance(inner, WeightedPart):
inner.weight *= w
return inner
return WeightedPart(text=_extract_text(inner), weight=w)
return args[0] if args else WeightedPart(text="")
def scheduled(self, args):
# [before:after:when] or [:after:when] or [before::when]
# args structure varies
when_str = _extract_text(args[-2]) if len(args) >= 2 else "0.5"
when_pct = _convert_when_to_percent(when_str, steps)
if len(args) >= 3:
before = args[0] or ""
after = args[1] or ""
else:
before = ""
after = args[0] or ""
# Create two parts: before and after
result = []
if before:
result.append(
WeightedPart(
text=_extract_text(before),
start_percent=0.0,
end_percent=when_pct,
)
)
if after:
result.append(
WeightedPart(
text=_extract_text(after),
start_percent=when_pct,
end_percent=1.0,
)
)
return result
def alternate(self, args):
options = [_extract_text(a) if a else "" for a in args]
return WeightedPart(
text="", is_alternating=True, alternating_options=options
)
def prompt(self, args):
return args
def start(self, args):
return args
try:
result = PartExtractor().transform(tree)
# Flatten result
def flatten(items):
for item in items:
if isinstance(item, list):
yield from flatten(item)
elif isinstance(item, WeightedPart):
yield item
elif item is not None:
yield WeightedPart(text=str(item))
parts = list(flatten(result if isinstance(result, list) else [result]))
except Exception:
parts = [WeightedPart(text=tree)]
return parts if parts else [WeightedPart(text="")]
# ============ SIMPLE FALLBACK PARSER (no Lark) ============
# Regex patterns for simple parser
re_schedule_switch = re.compile(r"^\[([^:\[\]]*):([^:\[\]]*):([0-9.]+)\]$")
re_schedule_remove = re.compile(r"^\[([^:\[\]]*)::([0-9.]+)\]$")
re_schedule_add = re.compile(r"^\[([^:\[\]]*):([0-9.]+)\]$")
re_alternation = re.compile(r"^\[([^|\]]+(?:\|[^|\]]+)+)\]$")
re_attention = re.compile(
r"""
\\\\|
\\\)|
\\\[|
\\]|
\\\\|
\\|
\[[^\]|]*\|[^\]|]*\]|
\[[^\]:]*:[^\]]*\]|
\(|
\[|
:\s*([+-]?[.\d]+)\s*\)|
\)|
]|
[^\\()\[\]:]+|
:
""",
re.X,
)
def _parse_chunk_simple(chunk: str, steps: Optional[int]) -> List[WeightedPart]:
"""Simple regex-based parsing (fallback when Lark not available)."""
parsed_weights = _parse_weight_simple(chunk)
parts = []
for text, weight in parsed_weights:
# Check for scheduling patterns
m_switch = re_schedule_switch.search(text)
if m_switch:
p_from, p_to, p_when = m_switch.groups()
val = _convert_when_to_percent(p_when, steps)
parts.append(
WeightedPart(
text=p_from, weight=weight, start_percent=0.0, end_percent=val
)
)
parts.append(
WeightedPart(
text=p_to, weight=weight, start_percent=val, end_percent=1.0
)
)
continue
m_remove = re_schedule_remove.search(text)
if m_remove:
p_from, p_when = m_remove.groups()
val = _convert_when_to_percent(p_when, steps)
parts.append(
WeightedPart(
text=p_from, weight=weight, start_percent=0.0, end_percent=val
)
)
continue
m_add = re_schedule_add.search(text)
if m_add:
p_to, p_when = m_add.groups()
val = _convert_when_to_percent(p_when, steps)
parts.append(
WeightedPart(
text=p_to, weight=weight, start_percent=val, end_percent=1.0
)
)
continue
m_alt = re_alternation.search(text)
if m_alt:
options = m_alt.group(1).split("|")
parts.append(
WeightedPart(
text=text,
weight=weight,
is_alternating=True,
alternating_options=options,
)
)
continue
parts.append(WeightedPart(text=text, weight=weight))
return parts if parts else [WeightedPart(text="")]
def _parse_weight_simple(text: str) -> List[tuple]:
"""Parse emphasis weights using regex."""
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 m in re_attention.finditer(text):
token = m.group(0)
weight = m.group(1)
if token.startswith("\\"):
res.append([token[1:], 1.0])
elif (token.startswith("[") and "|" in token and token.endswith("]")) or (
token.startswith("[") and ":" in token and token.endswith("]")
):
res.append([token, 1.0])
elif token == "(":
round_brackets.append(len(res))
elif token == "[":
square_brackets.append(len(res))
elif weight is not None and round_brackets:
multiply_range(round_brackets.pop(), float(weight))
elif token == ")" and round_brackets:
multiply_range(round_brackets.pop(), round_bracket_multiplier)
elif token == "]" and square_brackets:
multiply_range(square_brackets.pop(), square_bracket_multiplier)
else:
res.append([token, 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 not res:
res = [["", 1.0]]
# Merge consecutive identical weights
i = 0
while i + 1 < len(res):
if res[i][1] == res[i + 1][1]:
res[i][0] += res[i + 1][0]
res.pop(i + 1)
else:
i += 1
return [(t, w) for t, w in res]