# Created by Jake Carter @ https://github.com/JakeCarterDPM/stable-diffusion-webui-prompt-refactor # Rewritten as Prompt Parser Analyzer based on advanced prompt parser # Fixed emphasized handling to avoid 'Tree' object has no attribute 'type' error import re import gradio as gr from modules import script_callbacks, scripts, shared from modules.shared import opts from collections import namedtuple import lark import random from functools import lru_cache import hashlib from itertools import product import os import logging import traceback logger = logging.getLogger(__name__) # Feature flags (can be overridden via env) def _env_bool(name: str, default: str = "0") -> bool: v = str(os.getenv(name, default)).strip().lower() return v not in ("0", "", "false", "no", "off") ALLOW_EMPTY_ALTERNATE = _env_bool("ALLOW_EMPTY_ALTERNATE", "0") EXPAND_ALTERNATE_PER_STEP = _env_bool("EXPAND_ALTERNATE_PER_STEP", "1") GROUP_COMBO_LIMIT = int(os.getenv("GROUP_COMBO_LIMIT", "100")) CACHE_SIZE = int(os.getenv('PROMPT_PARSER_CACHE_SIZE', 4096)) # Lark Grammar _alt_rule = r' "[" prompt ("|" prompt)* "]" ' if not ALLOW_EMPTY_ALTERNATE else r' "[" prompt ("|" [prompt])+ "]" ' _grammar = r""" !start: (prompt | /[][():|]/+)* prompt: (scheduled | emphasized | grouped | alternate | alternate_distinct | alternate1 | alternate2 | top_level_sequence | sequence | compound | numbered | and_rule | plain | WHITESPACE)* !emphasized: "(" prompt ")" | "(" prompt ":" prompt ")" | "(" prompt ":" NUMBER ")" | "[" prompt "]" scheduled: "[" [prompt (":" prompt)+] "]" ":" NUMBER (step_range_list | reverse_flag | step_range_list reverse_flag)? reverse_flag: "reverse" | "r" step_range_list: step_range ("," step_range)* step_range: NUMBER "-" NUMBER | NUMBER "%" "-" NUMBER "%" alternate: """ + _alt_rule + r""" !alternate_distinct: "[" prompt ("|" prompt)* "]!" alternate1: (prompt) "|" (prompt)+ alternate2: (plain | compound) ("|" (plain | compound))+ grouped: "{" ((NUMBER_Q | prompt | sequence | grouped) ("," | "|")?)+ "}" top_level_sequence: prompt ("::" sequence)+ "!!" ("," plain)? sequence: prompt "::" prompt ("," | WHITESPACE)* nested_sequence* ("!" | ";") nested_sequence: "::" prompt ("," | WHITESPACE)* ("!" | ";" | "~") compound: /[a-zA-Z0-9]+(_[a-zA-Z0-9]+)+/ numbered: NUMBER_Q ("!" | "_")? (grouped | sequence | compound | and_rule | plain | alternate | alternate_distinct | alternate1 | alternate2) and_rule: (plain | compound) ("&" (plain | compound))+ WHITESPACE: /\s+/ plain: /([^\\\[\]()&]|\\.)+/ %import common.SIGNED_NUMBER -> NUMBER %import common.INT -> NUMBER_Q """ schedule_parser = lark.Lark(_grammar) @lru_cache(maxsize=CACHE_SIZE) def hash_tree(tree: lark.Tree | lark.Token) -> str: if isinstance(tree, lark.Tree): return hashlib.md5((tree.data + ''.join(hash_tree(c) for c in tree.children)).encode()).hexdigest() return hashlib.md5(str(tree).encode()).hexdigest() def resolve_tree(tree: lark.Tree | lark.Token, keep_spacing: bool = True) -> str: if isinstance(tree, lark.Tree): children = [] for child in tree.children: if isinstance(child, lark.Token) and child.type == "WHITESPACE": if keep_spacing: children.append(" ") continue children.append(resolve_tree(child, keep_spacing)) result = "".join(str(c) for c in children if c) return re.sub(r"[\s\u2028\u2029]+", " ", result).strip() if keep_spacing else result.strip() return str(tree).strip() class ScheduleTransformer(lark.Transformer): def __init__(self, total_steps: int, current_step: int = 1, seed: int | None = 42): super().__init__() self.total_steps = total_steps self.current_step = current_step self.seed = seed self.rng = random.Random(seed) if seed is not None else random def start(self, args): return "".join(str(arg) for arg in args if arg) def prompt(self, args): return "".join(str(arg) for arg in args if arg) def plain(self, args): return args[0].value def compound(self, args): return "_".join(str(arg) for arg in args) def and_rule(self, args): return " and ".join(resolve_tree(arg, keep_spacing=True) for arg in args if resolve_tree(arg)) def grouped(self, args): return ", ".join(resolve_tree(arg, keep_spacing=True) for arg in args if resolve_tree(arg).strip(" ,|")) def alternate(self, args): vals = [] for arg in args: s = resolve_tree(arg, keep_spacing=True) if s or s == "": vals.append(s) return vals[(self.current_step - 1) % len(vals)] if vals else "empty_prompt" def alternate_distinct(self, args): options = [resolve_tree(arg, keep_spacing=True) for arg in args if resolve_tree(arg)] return self.rng.choice(options) if options else "empty_prompt" def alternate1(self, args): options = [resolve_tree(arg, keep_spacing=True) for arg in args if resolve_tree(arg)] return self.rng.choice(options) if options else "empty_prompt" def alternate2(self, args): options = [resolve_tree(arg, keep_spacing=True) for arg in args if resolve_tree(arg)] return self.rng.choice(options) if options else "empty_prompt" def numbered(self, args): quantity = int(args[0]) distinct = False if len(args) > 1: mark = str(args[1]) distinct = mark in ("!", "_") target = args[-1] options = [] if isinstance(target, lark.Tree) and target.data in ("alternate", "alternate1", "alternate2"): for child in target.children: val = self.visit(child) if val: options.append(val) elif isinstance(target, lark.Token): options = [resolve_tree(target, keep_spacing=True)] else: for child in target.children: val = self.visit(child) if val: options.append(val) if not options: return "empty_prompt" if distinct: if quantity >= len(options): unique = self.rng.sample(options, len(options)) if options else [] pad = self.rng.choices(options, k=quantity - len(unique)) if quantity > len(unique) else [] selected = unique + pad else: selected = self.rng.sample(options, quantity) else: selected = self.rng.choices(options, k=quantity) return ", ".join(selected) def sequence(self, args, parent=None): owner = resolve_tree(args[0], keep_spacing=True) if parent is None else parent descriptors = [resolve_tree(arg, keep_spacing=True).strip(" ,~!;") for arg in args[1:] if resolve_tree(arg).strip(" ,~!;")] return f"{owner}: {', '.join(descriptors)}" def top_level_sequence(self, args): owner = resolve_tree(args[0], keep_spacing=True).strip() sequences = [] trailing_text = [] for child in args[1:]: if isinstance(child, lark.Tree) and child.data == "sequence": sequences.append(self.sequence(child.children, owner)) elif isinstance(child, str) and child.strip() == "!!": continue else: t = resolve_tree(child, keep_spacing=True).strip(" ,") if t: trailing_text.append(t) text = f"{owner} -> {', '.join(sequences)}" if trailing_text: text += f", {', '.join(trailing_text)}" return text def nested_sequence(self, args): elements = [resolve_tree(arg, keep_spacing=True).strip(" ,~!;") for arg in args[:-1] if resolve_tree(arg).strip(" ,~!;")] terminator = args[-1] if args and isinstance(args[-1], str) else None if terminator == "~": return self.rng.choice(elements) if elements else "empty_prompt" return f"[{' | '.join(elements)}]" def emphasized(self, args): prompt = args[0] if len(args) > 1: if isinstance(args[1], lark.Token) and args[1].type == "NUMBER": weight = float(args[1].value) return f"({prompt}:{weight})" else: second = args[1] return f"({prompt}:{second})" else: return f"({prompt}:1.1)" def scheduled(self, args): prompts = [arg for arg in args[:-1] if not isinstance(arg, lark.Token) or arg.type != "NUMBER"] number_node = args[-1] if isinstance(number_node, lark.Tree): number_node = resolve_tree(number_node, keep_spacing=True) try: weight = float(number_node) except ValueError: weight = 1.0 boundary = int(weight * self.total_steps) if weight <= 1.0 else int(weight) boundary = max(1, min(boundary, self.total_steps)) if not prompts: return "empty_prompt" if len(prompts) == 1: return f"({resolve_tree(prompts[0], keep_spacing=True)}:{weight})" if self.current_step >= boundary else "" step_increment = boundary / max(1, len(prompts)) for i, prompt in enumerate(prompts): step = min(self.total_steps, int(i * step_increment)) if i < len(prompts) - 1 else self.total_steps if self.current_step <= step: return f"({resolve_tree(prompt, keep_spacing=True)}:{weight})" return f"({resolve_tree(prompts[-1], keep_spacing=True)}:{weight})" class CollectSteps(lark.Visitor): def __init__(self, steps, prefix="", suffix="", depth=0, use_scheduling=True, seed=None): super().__init__() self.steps = steps self.prefix = prefix self.suffix = suffix self.depth = depth self.use_scheduling = use_scheduling self.seed = seed self.rng = random.Random(seed) if seed is not None else random self.schedules = [] def visit(self, tree): if isinstance(tree, lark.Tree): method_name = f"visit_{tree.data}" method = getattr(self, method_name, self._default_visit) return method(tree) elif isinstance(tree, lark.Token): return self._visit_token(tree) return [] def _default_visit(self, tree): schedules = [] for i, child in enumerate(tree.children): if isinstance(child, lark.Token) and child.type == "WHITESPACE": continue pre = "".join(resolve_tree(c, keep_spacing=True) for j, c in enumerate(tree.children) if j < i and not (isinstance(c, lark.Token) and c.type == "WHITESPACE")) post = "".join(resolve_tree(c, keep_spacing=True) for j, c in enumerate(tree.children) if j > i and not (isinstance(c, lark.Token) and c.type == "WHITESPACE")) collector = CollectSteps(self.steps, prefix=self.prefix + pre, suffix=post + self.suffix, depth=self.depth + 1, use_scheduling=self.use_scheduling, seed=self.seed) child_schedules = collector.visit(child) schedules.extend(child_schedules) return schedules def _visit_token(self, token): if token.type == "WHITESPACE": return [] return [[self.steps, self.prefix + str(token) + self.suffix]] def visit_plain(self, tree): text = resolve_tree(tree, keep_spacing=True) return [[self.steps, self.prefix + text + self.suffix]] def visit_top_level_sequence(self, tree): transformer = ScheduleTransformer(self.steps, 1, self.seed) text = transformer.transform(tree) return [[self.steps, self.prefix + text + self.suffix]] def visit_scheduled(self, tree): if not tree.children: return [[self.steps, self.prefix + "empty_prompt" + self.suffix]] prompts = [ p for p in tree.children if not (isinstance(p, lark.Token) and p.type == "NUMBER") and not (isinstance(p, lark.Tree) and p.data in ("step_range_list", "reverse_flag")) ] number_node = next((p for p in tree.children if isinstance(p, lark.Token) and p.type == "NUMBER"), None) step_range_list = next((p for p in tree.children if isinstance(p, lark.Tree) and p.data == "step_range_list"), None) is_reverse = any(isinstance(p, lark.Tree) and p.data == "reverse_flag" for p in tree.children) weight = float(number_node.value) if number_node else 1.0 def _clamp_step(x: int) -> int: return max(1, min(x, self.steps)) step_intervals = [] explicit_ranges = False if step_range_list: explicit_ranges = True for sr in step_range_list.children: if not (isinstance(sr, lark.Tree) and sr.data == "step_range"): continue if len(sr.children) != 2: continue start_txt = resolve_tree(sr.children[0], keep_spacing=False) end_txt = resolve_tree(sr.children[1], keep_spacing=False) def _to_steps(txt: str) -> int: s = txt.strip() if s.endswith("%"): try: return int(round(float(s[:-1]) / 100.0 * self.steps)) except ValueError: return 1 try: return int(round(float(s))) except ValueError: return 1 start_step = _clamp_step(_to_steps(start_txt)) end_step = _clamp_step(_to_steps(end_txt)) if start_step < end_step: step_intervals.append((start_step, end_step)) else: num_prompts = len(prompts) boundary = _clamp_step(int(round(weight * self.steps)) if weight <= 1.0 else int(round(weight))) if num_prompts == 1: schedules = [] schedules.append([boundary - 1, self.prefix + self.suffix]) last_text = resolve_tree(prompts[0], keep_spacing=True) schedules.append([self.steps, self.prefix + last_text + self.suffix]) return schedules if boundary < num_prompts: boundary = num_prompts step_size = boundary / num_prompts for i in range(num_prompts): start = _clamp_step(int(round(i * step_size)) + 1) end = _clamp_step(int(round((i + 1) * step_size))) if start < end: step_intervals.append((start, end)) if is_reverse: prompts = prompts[::-1] step_intervals = step_intervals[::-1] schedules = [] if step_intervals and step_intervals[0][0] > 1: schedules.append([step_intervals[0][0] - 1, self.prefix + self.suffix]) for i, (start, end) in enumerate(step_intervals): end = min(end, self.steps) if start < end: p = prompts[i] if isinstance(p, lark.Tree): child_schedules = self.visit(p) else: text = resolve_tree(p, keep_spacing=True) child_schedules = [[self.steps, text]] for sched in child_schedules: schedules.append([end, self.prefix + sched[1] + self.suffix]) if step_intervals and step_intervals[-1][1] < self.steps: tail_text = resolve_tree(prompts[-1], keep_spacing=True) schedules.append([self.steps, self.prefix + tail_text + self.suffix]) if not schedules: return [[self.steps, self.prefix + resolve_tree(tree, keep_spacing=True) + self.suffix]] return schedules def visit_alternate(self, tree): options = [] for child in tree.children: if isinstance(child, lark.Token) and child.type == "WHITESPACE": continue child_schedules = self.visit(child) child_options = [ sched[1].strip(" ,|") for sched in child_schedules if sched[1].strip(" ,|") ] options.append( child_options or [resolve_tree(child, keep_spacing=True).strip(" ,|")] ) if not options: return [[self.steps, self.prefix + "empty_prompt" + self.suffix]] if EXPAND_ALTERNATE_PER_STEP: schedules = [] for step in range(1, self.steps + 1): option = options[(step - 1) % len(options)] for sched in option: schedules.append([step, self.prefix + sched + self.suffix]) return schedules else: group = options[self.rng.randrange(len(options))] choice = self.rng.choice(group) if group else "empty_prompt" return [[self.steps, self.prefix + choice + self.suffix]] def visit_alternate_distinct(self, tree): options = [] for child in tree.children: if isinstance(child, lark.Token) and child.type == "WHITESPACE": continue child_schedules = self.visit(child) child_options = [ sched[1].strip(" ,|") for sched in child_schedules if sched[1].strip(" ,|") ] options.append( child_options or [resolve_tree(child, keep_spacing=True).strip(" ,|")] ) flat = [opt for group in options for opt in group] if not flat: return [[self.steps, self.prefix + "empty_prompt" + self.suffix]] selected = self.rng.choice(flat) return [[self.steps, self.prefix + selected + self.suffix]] def visit_alternate1(self, tree): return self.visit_alternate_distinct(tree) def visit_alternate2(self, tree): options = [resolve_tree(c).strip() for c in tree.children] combined_options = [] for option in options: if "_" in option: combined_options.append(option) else: suffix = options[0].split("_")[-1] if "_" in options[0] else "" combined_options.append(f"{option}_{suffix}" if suffix else option) return [[self.steps, self.prefix + "|".join(combined_options) + self.suffix]] def visit_grouped(self, tree): all_options = [] for child in tree.children: if isinstance(child, lark.Token) and child.type == "WHITESPACE": continue child_schedules = self.visit(child) child_options = [sched[1].strip(" ,|") for sched in child_schedules if sched[1].strip(" ,|")] all_options.append(child_options or [resolve_tree(child, keep_spacing=True).strip(" ,|")]) out = [] for i, combo in enumerate(product(*all_options)): if i >= GROUP_COMBO_LIMIT: break text = ", ".join(combo).strip() if text: out.append([self.steps, self.prefix + text + self.suffix]) return out or [[self.steps, self.prefix + "empty_prompt" + self.suffix]] def visit_sequence(self, tree): transformer = ScheduleTransformer(self.steps, 1, self.seed) text = transformer.transform(tree) return [[self.steps, self.prefix + text + self.suffix]] def visit_nested_sequence(self, tree): elements = [resolve_tree(child, keep_spacing=True).strip(" ,~!;") for child in tree.children[:-1] if resolve_tree(child).strip(" ,~!;")] terminator = tree.children[-1].value if tree.children and isinstance(tree.children[-1], lark.Token) else None if terminator == "~": text = self.rng.choice(elements) if elements else "empty_prompt" else: text = f"[{' | '.join(elements)}]" return [[self.steps, self.prefix + text + self.suffix]] def visit_numbered(self, tree): quantity = int(tree.children[0]) distinct = False if len(tree.children) > 1: mark = str(tree.children[1]) distinct = mark in ("!", "_") target = tree.children[-1] child_schedules = self.visit(target) options = [ sched[1].strip(" ,|") for sched in child_schedules if sched[1].strip(" ,|") ] if not options: options = [resolve_tree(target, keep_spacing=True).strip(" ,|")] if not options: return [[self.steps, self.prefix + "empty_prompt" + self.suffix]] if distinct: if quantity >= len(options): unique = self.rng.sample(options, len(options)) if options else [] pad = self.rng.choices(options, k=quantity - len(unique)) if quantity > len(unique) else [] selected = unique + pad else: selected = self.rng.sample(options, quantity) else: selected = self.rng.choices(options, k=quantity) return [[self.steps, self.prefix + ", ".join(selected) + self.suffix]] def visit_and_rule(self, tree): text = " and ".join(resolve_tree(c, keep_spacing=True) for c in tree.children if resolve_tree(c, keep_spacing=True)) return [[self.steps, self.prefix + text + self.suffix]] def visit_emphasized(self, tree): prompt = resolve_tree(tree.children[0], keep_spacing=True) if len(tree.children) > 1: if isinstance(tree.children[1], lark.Token) and tree.children[1].type == "NUMBER": weight = float(tree.children[1].value) text = f"({prompt}:{weight})" else: second = resolve_tree(tree.children[1], keep_spacing=True) text = f"({prompt}:{second})" else: text = f"({prompt}:1.1)" return [[self.steps, self.prefix + text + self.suffix]] def __call__(self, tree): self.schedules = self.visit(tree) return self.schedules or [[self.steps, self.prefix + resolve_tree(tree, keep_spacing=True) + self.suffix]] def at_step_from_schedule(step, schedule): if not schedule: return "" for end_step, text in schedule: if step <= int(end_step): return text return schedule[-1][1] def at_step(step: int, prompt_or_schedule, *, steps: int | None = None, seed: int | None = 42, use_visitor: bool = True) -> str: if isinstance(prompt_or_schedule, list) and prompt_or_schedule and isinstance(prompt_or_schedule[0], list): return at_step_from_schedule(step, prompt_or_schedule) prompt = str(prompt_or_schedule) if steps is None: raise ValueError("steps is required when passing a prompt string to at_step(...)") sched = get_schedule(prompt, steps, True, seed, use_visitor) return at_step_from_schedule(step, sched) @lru_cache(maxsize=CACHE_SIZE) def get_schedule(prompt: str, steps: int, use_scheduling: bool, seed: int | None, use_visitor: bool = True): try: tree = schedule_parser.parse(prompt) except lark.exceptions.LarkError as e: logger.warning("Prompt parse failed: '%s' — %s", prompt, e) return [[steps, prompt]] collector = CollectSteps(steps, use_scheduling=use_scheduling, seed=seed) schedules = collector(tree) if not use_visitor: rebuilt = [] for end, _ in schedules: transformer = ScheduleTransformer(steps, end, seed) text = transformer.transform(tree) rebuilt.append([end, text]) return rebuilt return schedules def analyze_prompt(input_prompt, steps, seed, use_visitor, output_mode, specific_step): tree_str = "" schedule_str = "" result = "" error_msg = "" try: tree = schedule_parser.parse(input_prompt) tree_str = tree.pretty() except lark.exceptions.LarkError as e: error_msg = f"Parse Error: {str(e)}\n\nPossible causes:\n- Unbalanced brackets or invalid syntax.\n- Missing colons, commas, or incorrect operators.\n- Suggestion: Validate grammar rules for scheduled, alternate, grouped, sequences, etc.\n- Ensure no invalid characters in plain text.\nTraceback: {traceback.format_exc()}" return result, tree_str, schedule_str, error_msg try: schedule = get_schedule(input_prompt, steps, True, seed, use_visitor) schedule_str = "\n".join([f"Up to step {end}: {text}" for end, text in schedule]) except Exception as e: error_msg = f"Schedule Error: {str(e)}\nTraceback: {traceback.format_exc()}" return result, tree_str, schedule_str, error_msg if output_mode == "Full Schedule": result = schedule_str elif output_mode == "Parse Tree": result = tree_str elif output_mode == "At Specific Step": result = at_step_from_schedule(specific_step, schedule) return result, tree_str, schedule_str, error_msg # Helper functions for converting prompt to specific formats def convert_to_grouped(input_prompt): return f"{{{input_prompt}}}" def convert_to_sequence(input_prompt): parts = [p.strip() for p in input_prompt.split(',')] if len(parts) < 2: return input_prompt + "::descriptor!" owner = parts[0] descriptors = ', '.join(parts[1:]) return f"{owner}::{descriptors}!" def convert_to_numbered(input_prompt, num, distinct): mark = "!" if distinct else "" return f"{num}{mark} {{{input_prompt}}}" class PromptParserAnalyzerScript(scripts.Script): def title(self): return "Prompt Parser Analyzer" def show(self, is_txt2img): return scripts.AlwaysVisible def ui(self, is_txt2img): with gr.Group(): with gr.Accordion("Prompt Parser Analyzer", open=False): with gr.Tab("Analyze Prompt"): input_prompt = gr.Textbox(label="Input Prompt", lines=5) steps = gr.Number(label="Steps", value=100, precision=0) seed = gr.Number(label="Seed (for random elements)", value=42, precision=0) use_visitor = gr.Checkbox(label="Use Visitor Mode (faster for simple prompts)", value=True) output_mode = gr.Dropdown(choices=["Full Schedule", "Parse Tree", "At Specific Step"], label="Output Mode", value="Full Schedule") specific_step = gr.Number(label="Specific Step (for At Specific Step mode)", value=50, precision=0) analyze_btn = gr.Button("Analyze Prompt") output_result = gr.Textbox(label="Analysis Result", lines=10) output_tree = gr.Textbox(label="Parse Tree (Debug)", lines=5) output_schedule = gr.Textbox(label="Full Schedule (Debug)", lines=5) error_output = gr.Textbox(label="Errors & Suggestions", lines=5) analyze_btn.click( fn=analyze_prompt, inputs=[input_prompt, steps, seed, use_visitor, output_mode, specific_step], outputs=[output_result, output_tree, output_schedule, error_output] ) with gr.Tab("Format Converters"): gr.HTML(value="
These helpers convert plain prompts to advanced formats. Read the explanations for usage.
") with gr.Accordion("Grouped {} - For combinations of attributes"): gr.HTML(value="Wraps items in {}, generates combinations if | (alternates) inside.
Example: {red|blue, car|bike} resolves to 'red, car', 'red, bike', 'blue, car', 'blue, bike'.
Use for multiple independent attributes like colors, objects. Limit combos with GROUP_COMBO_LIMIT env var.
Structures as owner::descriptors!. Nested with :: and ~ for random or ! for close.
Example: character::hair:ponytail, eyes:blue! -> 'character: hair ponytail, eyes blue'.
Use for hierarchical entities like characters, outfits. Close with ! or ;. Top-level with !!! for groups.
N{group} repeats N times, ! or _ for distinct (no repeats).
Example: 3! {a|b|c} -> 'a, b, c' (unique sample).
Use for multiples like '3 cats'. If N > options, pads with repeats unless distinct.