Files

612 lines
17 KiB
Python

import json
import os
import random
import re
from pathlib import Path
from typing import Any
USER_DIR = Path(os.getcwd()) / "user" / "default"
CONFIG_DIR = USER_DIR / "ComfyUI-Complex-Prompt"
CONFIG_PATH = CONFIG_DIR / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {}
CONDITION_FUNCTIONS = {
"float": float,
"int": int,
"str": str,
}
def log(message: str) -> None:
print(f"[ComfyUI-Complex-Prompt]: {message}")
def read_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as file:
return json.load(file)
def write_json(path: Path, data: dict[str, Any]) -> None:
with path.open("w", encoding="utf-8") as file:
json.dump(data, file, ensure_ascii=False, indent=2)
file.write("\n")
def load_config() -> dict[str, Any]:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if not CONFIG_PATH.exists():
write_json(CONFIG_PATH, DEFAULT_CONFIG)
log(f"Created config file at {CONFIG_PATH}")
return DEFAULT_CONFIG.copy()
log(f"Reading config from {CONFIG_PATH}")
return read_json(CONFIG_PATH)
CONFIG = load_config()
ARTEMKO7V_COMPLEX_PROMPT_VARS = "ArtemKo7vComplexPromptVars"
VAR_PATTERN = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
DYNAMIC_VARIANT_PATTERN = re.compile(r"\{[^{}]*\}")
_PROMPT_GENERATOR = None
class ConditionValue:
def __init__(self, value: Any):
self.value = value
def __str__(self) -> str:
return str(self.value)
def __repr__(self) -> str:
return repr(str(self))
def __bool__(self) -> bool:
return bool(str(self))
def __int__(self) -> int:
return int(str(self))
def __float__(self) -> float:
return float(str(self))
def __eq__(self, other: Any) -> bool:
return str(self) == str(self.unwrap(other))
def __ne__(self, other: Any) -> bool:
return not self == other
def __lt__(self, other: Any) -> bool:
return self.compare(other, lambda left, right: left < right)
def __le__(self, other: Any) -> bool:
return self.compare(other, lambda left, right: left <= right)
def __gt__(self, other: Any) -> bool:
return self.compare(other, lambda left, right: left > right)
def __ge__(self, other: Any) -> bool:
return self.compare(other, lambda left, right: left >= right)
def unwrap(self, other: Any) -> Any:
if isinstance(other, ConditionValue):
return other.value
return other
def compare(self, other: Any, operation):
other = self.unwrap(other)
try:
return operation(float(self), float(other))
except (TypeError, ValueError):
return operation(str(self), str(other))
def get_prompt_generator():
global _PROMPT_GENERATOR
if _PROMPT_GENERATOR is None:
try:
from dynamicprompts.generators import RandomPromptGenerator
except ImportError as error:
raise ImportError(
"ComfyUI-Complex-Prompt requires the 'dynamicprompts' package. "
"Install this extension's requirements.txt dependencies."
) from error
_PROMPT_GENERATOR = RandomPromptGenerator()
return _PROMPT_GENERATOR
def mask_vars(prompt: str) -> tuple[str, dict[str, str]]:
placeholders: dict[str, str] = {}
def replace(match: re.Match[str]) -> str:
placeholder = f"AK7VCOMPLEXPROMPTVAR{len(placeholders)}TOKEN"
placeholders[placeholder] = match.group(0)
return placeholder
return VAR_PATTERN.sub(replace, prompt), placeholders
def restore_vars(prompt: str, placeholders: dict[str, str]) -> str:
for placeholder, variable in placeholders.items():
prompt = prompt.replace(placeholder, variable)
return prompt
def has_dynamic_prompt_syntax(prompt: str) -> bool:
return bool(DYNAMIC_VARIANT_PATTERN.search(prompt)) or "__" in prompt
def generate_dynamic_prompt(prompt: str | None, seed: int) -> str:
if prompt in (None, ""):
return ""
masked_prompt, placeholders = mask_vars(prompt)
if not has_dynamic_prompt_syntax(masked_prompt):
return prompt
generator = get_prompt_generator()
# dynamicprompts treats the integer 0 as an omitted seed; [0] is explicit.
prompts = generator.generate(masked_prompt, 1, seeds=[0] if seed == 0 else seed)
generated_prompt = prompts[0] if prompts else ""
return restore_vars(generated_prompt, placeholders)
def apply_vars(prompt: str, vars: dict[str, str] | None = None) -> str:
if not vars:
return prompt
def replace(match: re.Match[str]) -> str:
variable_name = match.group(1)
if variable_name not in vars:
return match.group(0)
return str(vars[variable_name])
return VAR_PATTERN.sub(replace, prompt)
def resolve_prompt(
prompt: str | None,
seed: int,
vars: dict[str, Any] | None = None,
) -> str:
"""Expand choices, then recursively resolve the selected variable values."""
def resolve(value: str | None, resolving: frozenset[str]) -> str:
generated = generate_dynamic_prompt(value, seed)
if not vars:
return generated
def replace(match: re.Match[str]) -> str:
name = match.group(1)
# Keep unknown and cyclic references visible without expanding forever.
if name not in vars or name in resolving:
return match.group(0)
return resolve(str(vars[name]), resolving | {name})
return VAR_PATTERN.sub(replace, generated)
return resolve(prompt, frozenset())
def is_json_variable_value(value: Any) -> bool:
return isinstance(value, (int, float, str)) and not isinstance(value, bool)
def normalize_json_object_text(json_text: str) -> str:
json_text = json_text.strip()
if not json_text:
return "{}"
if json_text.startswith(("{", "[")):
return json_text
return "{" + json_text + "}"
def parse_json_vars(
json_text: str,
seed: int,
vars: dict[str, Any] | None = None,
) -> dict[str, Any]:
json_text = normalize_json_object_text(json_text)
try:
parsed = json.loads(json_text)
except json.JSONDecodeError as error:
raise ValueError(f"Invalid JSON: {error.msg}") from error
if not isinstance(parsed, dict):
raise ValueError("JSON must be an object with variable names as keys.")
result = dict(vars or {})
rng = random.Random(seed)
for key, value in parsed.items():
if is_json_variable_value(value):
result[key] = value
continue
if (
isinstance(value, list)
and value
and all(is_json_variable_value(item) for item in value)
):
result[key] = rng.choice(value)
return result
def parse_choice_map(choices: str) -> dict[str, str]:
choices = choices.strip()
if not choices:
return {}
if not choices.startswith("{"):
choices = "{" + choices + "}"
try:
parsed = json.loads(choices)
except json.JSONDecodeError as error:
raise ValueError(f"Invalid choices JSON: {error.msg}") from error
if not isinstance(parsed, dict):
raise ValueError("Choices must be a JSON object.")
result: dict[str, str] = {}
for key, value in parsed.items():
if is_json_variable_value(value):
result[str(key)] = str(value)
return result
def normalize_condition(condition: str) -> str:
normalized = VAR_PATTERN.sub(r"\1", condition)
normalized = normalized.replace("&&", " and ")
normalized = normalized.replace("||", " or ")
normalized = re.sub(r"!(?!=)", " not ", normalized)
return normalized
def should_set_variable(
condition: str,
vars: dict[str, str] | None = None,
) -> bool:
condition = condition.strip()
if not condition:
return True
try:
from simpleeval import NameNotDefined, SimpleEval
except ImportError as error:
raise ImportError(
"ComfyUI-Complex-Prompt conditional variables require the "
"'simpleeval' package. Install this extension's requirements.txt "
"dependencies."
) from error
evaluator = SimpleEval(
functions=CONDITION_FUNCTIONS,
names={
key: ConditionValue(value)
for key, value in (vars or {}).items()
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key)
},
)
try:
return bool(evaluator.eval(normalize_condition(condition)))
except NameNotDefined:
return False
class ArtemKo7vComplexPrompt:
CATEGORY = "ArtemKo7v"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("string",)
FUNCTION = "generate_prompt"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": (
"STRING",
{
"multiline": True,
"dynamicPrompts": False,
},
),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 18446744073709551615,
"control_after_generate": "randomize",
},
),
},
"optional": {
"vars": (ARTEMKO7V_COMPLEX_PROMPT_VARS,),
},
}
def generate_prompt(
self,
prompt: str,
seed: int,
vars: dict[str, str] | None = None,
):
return (resolve_prompt(prompt, seed, vars),)
class ArtemKo7vComplexPromptSetVariable:
CATEGORY = "ArtemKo7v"
RETURN_TYPES = (ARTEMKO7V_COMPLEX_PROMPT_VARS, "BOOLEAN")
RETURN_NAMES = ("vars", "wasSet")
FUNCTION = "set_variable"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"variable_name": (
"STRING",
{
"default": "",
},
),
"value": (
"STRING",
{
"multiline": True,
"dynamicPrompts": False,
},
),
"condition": (
"STRING",
{
"default": "",
},
),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 18446744073709551615,
"control_after_generate": "randomize",
},
),
"trim": (
"BOOLEAN",
{
"default": False,
},
),
},
"optional": {
"vars": (ARTEMKO7V_COMPLEX_PROMPT_VARS,),
},
}
def set_variable(
self,
variable_name: str,
value: str | None,
condition: str,
seed: int,
trim: bool = False,
vars: dict[str, str] | None = None,
):
result = dict(vars or {})
if not should_set_variable(condition, vars):
return (result, False)
value = "" if value is None else value
stored_value = resolve_prompt(value, seed, vars)
if trim:
stored_value = stored_value.strip()
result[variable_name] = stored_value
return (result, True)
class ArtemKo7vComplexPromptSetVariableByChoice:
CATEGORY = "ArtemKo7v"
RETURN_TYPES = (ARTEMKO7V_COMPLEX_PROMPT_VARS, "BOOLEAN")
RETURN_NAMES = ("vars", "wasSet")
FUNCTION = "set_variable_by_choice"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"variable_name": (
"STRING",
{
"default": "",
},
),
"source_variable": (
"STRING",
{
"default": "",
},
),
"choices": (
"STRING",
{
"multiline": True,
"dynamicPrompts": False,
"default": "",
},
),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 18446744073709551615,
"control_after_generate": "randomize",
},
),
"trim": (
"BOOLEAN",
{
"default": False,
},
),
},
"optional": {
"vars": (ARTEMKO7V_COMPLEX_PROMPT_VARS,),
},
}
def set_variable_by_choice(
self,
variable_name: str,
source_variable: str,
choices: str,
seed: int,
trim: bool = False,
vars: dict[str, str] | None = None,
):
result = dict(vars or {})
source_variable = source_variable.strip()
if source_variable.startswith("$"):
source_variable = source_variable[1:]
if not source_variable or source_variable not in result:
return (result, False)
choice_map = parse_choice_map(choices)
source_value = str(result[source_variable])
value = choice_map.get(source_value, choice_map.get("__default__"))
if value is None:
return (result, False)
stored_value = resolve_prompt(value, seed, vars)
if trim:
stored_value = stored_value.strip()
result[variable_name] = stored_value
return (result, True)
class ArtemKo7vComplexPromptParseJSON:
CATEGORY = "ArtemKo7v"
RETURN_TYPES = (ARTEMKO7V_COMPLEX_PROMPT_VARS,)
RETURN_NAMES = ("vars",)
FUNCTION = "parse_json"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"json_text": (
"STRING",
{
"multiline": True,
"dynamicPrompts": False,
"default": "",
},
),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 18446744073709551615,
"control_after_generate": "randomize",
},
),
},
"optional": {
"vars": (ARTEMKO7V_COMPLEX_PROMPT_VARS,),
},
}
def parse_json(
self,
json_text: str,
seed: int,
vars: dict[str, Any] | None = None,
):
return (parse_json_vars(json_text, seed, vars),)
class ArtemKo7vComplexPromptCombineVars:
"""Combines vars inputs in order, with later inputs taking precedence."""
CATEGORY = "ArtemKo7v"
MAX_VAR_INDEX = 64
RETURN_TYPES = (ARTEMKO7V_COMPLEX_PROMPT_VARS,)
RETURN_NAMES = ("vars",)
FUNCTION = "combine_vars"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"vars_1": (ARTEMKO7V_COMPLEX_PROMPT_VARS,),
},
}
def combine_vars(self, vars_1: dict[str, Any], **kwargs):
result = dict(vars_1 or {})
for index in range(2, self.MAX_VAR_INDEX + 1):
vars = kwargs.get(f"vars_{index}")
if vars:
result.update(vars)
return (result,)
class ArtemKo7vComplexPromptEmptyString:
CATEGORY = "ArtemKo7v"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("string",)
FUNCTION = "get_empty_string"
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
def get_empty_string(self):
return ("",)
NODE_CLASS_MAPPINGS = {
"ArtemKo7vComplexPrompt": ArtemKo7vComplexPrompt,
"ArtemKo7vComplexPromptSetVariable": ArtemKo7vComplexPromptSetVariable,
"ArtemKo7vComplexPromptSetVariableByChoice": (
ArtemKo7vComplexPromptSetVariableByChoice
),
"ArtemKo7vComplexPromptParseJSON": ArtemKo7vComplexPromptParseJSON,
"ArtemKo7vComplexPromptCombineVars": ArtemKo7vComplexPromptCombineVars,
"ArtemKo7vComplexPromptEmptyString": ArtemKo7vComplexPromptEmptyString,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ArtemKo7vComplexPrompt": "Complex Prompt",
"ArtemKo7vComplexPromptSetVariable": "Complex Prompt Set Variable",
"ArtemKo7vComplexPromptSetVariableByChoice": (
"Complex Prompt Set Variable By Choice"
),
"ArtemKo7vComplexPromptParseJSON": "Complex Prompt Parse JSON",
"ArtemKo7vComplexPromptCombineVars": "Complex Prompt Combine Vars",
"ArtemKo7vComplexPromptEmptyString": "Complex Prompt Empty String",
}