Files
ruminar-ComfyUI-PromptRando…/nodes.py
T
るみなー先生 08b86f3683 feat: update project version and description; add runtime state management and server routes
- Bump version to 0.6.0 and enhance project description in pyproject.toml.
- Introduce runtime state management with RuntimeCandidateState and RuntimeCandidateStateStore classes to handle candidate updates and state retrieval.
- Implement server routes for updating and reading runtime candidate states, handling various request scenarios and errors.
- Add tests for runtime layout and prompt random choice functionality, ensuring robust validation and state management.
- Enhance frontend with runtime candidate editor, including UI updates and synchronization with server state.
2026-08-19 20:39:39 +09:00

357 lines
10 KiB
Python

from __future__ import annotations
import re
import secrets
import time
try:
from .candidate_parser import (
UnsupportedCandidateSyntaxError,
build_ex_pool,
build_flat_pool,
)
from .runtime_state import RUNTIME_CANDIDATE_STATE_STORE
except ImportError:
from candidate_parser import (
UnsupportedCandidateSyntaxError,
build_ex_pool,
build_flat_pool,
)
from runtime_state import RUNTIME_CANDIDATE_STATE_STORE
DEFAULT_OPTIONS_TEXT = """town,
girl's room,
park,
lake,
flower garden,
castle, fortress,
forest,
grasslands,
sea,
snowy landscape,
mountain,
flower field,
starry sky,
coffee shop,"""
DEFAULT_OPTIONS_TEXT_EX = """town
zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
coffee shop{cake|coffee cup}
amusement park{ferris wheel|carousel|balloons}
(){
white day
wedding ceremony
birthday party
}"""
MAX_SAFE_SEED = 2**53 - 1
CATEGORY = "Prompt Random Choice"
WINDOWS_RESERVED_NAMES = {
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{index}" for index in range(1, 10)),
*(f"LPT{index}" for index in range(1, 10)),
}
def _safe_text(value: str) -> str:
text = str(value or "")
if not text:
return "empty"
text = text.replace("\\", "/").replace("/", "_")
text = re.sub(r'[<>:"|?*\x00-\x1f]', "_", text)
text = re.sub(r"[\s,]+", "_", text)
text = text.replace("(", "_").replace(")", "_")
text = text.replace("{", "_").replace("}", "_")
text = re.sub(r"_+", "_", text)
text = text.strip().strip("._-")
if not text:
return "empty"
if text.upper() in WINDOWS_RESERVED_NAMES:
text = f"_{text}"
return text[:200].rstrip(" .") or "empty"
def _options_input(default: str) -> tuple[str, dict]:
return (
"STRING",
{"multiline": True, "default": default, "dynamicPrompts": False},
)
def _change_every_input() -> tuple[str, dict]:
return (
"INT",
{"default": 1, "min": 1, "max": 999999, "step": 1},
)
def _state_key_input() -> tuple[str, dict]:
return (
"STRING",
{"default": "", "multiline": False, "dynamicPrompts": False},
)
class _RandomChoiceStateMixin:
def __init__(self):
self._current_choice: str | None = None
self._repeat_index = 0
self._last_source_key: str | None = None
self._last_change_every: int | None = None
self._selected_revision = 0
@classmethod
def IS_CHANGED(cls, **kwargs):
return time.time_ns()
def _reset_selection(self):
self._current_choice = None
self._repeat_index = 0
self._selected_revision = 0
def _select_new_choice(self, pool, selected_revision: int):
self._current_choice = pool.choose()
self._repeat_index = 1
self._selected_revision = selected_revision
def _run_choice(
self,
pool,
change_every,
*,
source_key: str,
reset_on_source_change: bool,
latest_revision: int = 0,
):
change_every = max(1, int(change_every))
source_changed = self._last_source_key != source_key
interval_changed = (
self._last_change_every is not None
and self._last_change_every != change_every
)
needs_selection = self._current_choice is None or interval_changed
if reset_on_source_change and source_changed:
needs_selection = True
if not needs_selection:
if self._repeat_index < change_every:
self._repeat_index += 1
else:
needs_selection = True
self._last_source_key = source_key
self._last_change_every = change_every
if needs_selection:
if pool.leaf_count == 0:
self._reset_selection()
return "", "empty", "(empty)", 0, change_every, 0
self._select_new_choice(pool, latest_revision)
selected_text = str(self._current_choice or "")
return (
selected_text,
_safe_text(selected_text),
selected_text if selected_text else "(empty)",
self._repeat_index,
change_every,
self._selected_revision,
)
@staticmethod
def _format_result(choice_result, *, latest_revision=0, runtime_status=""):
(
selected_text,
selected_text_safe,
selected_text_title,
repeat_index,
change_every,
selected_revision,
) = choice_result
return {
"ui": {
"selected_text_title": [selected_text_title],
"repeat_index": [repeat_index],
"change_every": [change_every],
"latest_revision": [latest_revision],
"selected_revision": [selected_revision],
"runtime_status": [runtime_status],
},
"result": (selected_text, selected_text_safe),
}
class PromptRandomChoice(_RandomChoiceStateMixin):
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"options_text": _options_input(DEFAULT_OPTIONS_TEXT),
"change_every": _change_every_input(),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("selected_text", "selected_text_safe")
FUNCTION = "pick"
CATEGORY = CATEGORY
def pick(self, options_text, change_every):
source = str(options_text or "")
result = self._run_choice(
build_flat_pool(source, allow_force=False),
change_every,
source_key=source,
reset_on_source_change=True,
)
return self._format_result(result)
class PromptRandomChoiceEx(_RandomChoiceStateMixin):
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"options_text": _options_input(DEFAULT_OPTIONS_TEXT_EX),
"change_every": _change_every_input(),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("selected_text", "selected_text_safe")
FUNCTION = "pick"
CATEGORY = CATEGORY
def pick(self, options_text, change_every):
source = str(options_text or "")
result = self._run_choice(
build_ex_pool(source, allow_force=False),
change_every,
source_key=source,
reset_on_source_change=True,
)
return self._format_result(result)
class _RuntimeRandomChoiceMixin(_RandomChoiceStateMixin):
IS_EX = False
STATE_KEY_PREFIX = "prc-runtime"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN")
@classmethod
def INPUT_TYPES(cls):
default = DEFAULT_OPTIONS_TEXT_EX if cls.IS_EX else DEFAULT_OPTIONS_TEXT
return {
"required": {
"options_text": _options_input(default),
"change_every": _change_every_input(),
"state_key": _state_key_input(),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
def _resolve_state_key(self, state_key, unique_id) -> str:
key = state_key.strip() if isinstance(state_key, str) else ""
kind = "ex" if self.IS_EX else "flat"
return key or f"{self.STATE_KEY_PREFIX}-{kind}-{unique_id}"
def _runtime_source(self, options_text, state_key, unique_id):
key = self._resolve_state_key(state_key, unique_id)
state = RUNTIME_CANDIDATE_STATE_STORE.get(key)
if state is None:
return str(options_text or ""), 0, "fallback"
expected_kind = "ex" if self.IS_EX else "flat"
if state.node_kind != expected_kind:
raise ValueError("Runtime state belongs to another node kind.")
if state.input_status == "hard_error":
raise UnsupportedCandidateSyntaxError(state.error)
if state.accepted_text is not None:
return state.accepted_text, state.revision, state.input_status
return str(options_text or ""), 0, state.input_status
def pick(self, options_text, change_every, state_key="", unique_id=None):
source, latest_revision, runtime_status = self._runtime_source(
options_text, state_key, unique_id
)
pool = (
build_ex_pool(source, allow_force=True)
if self.IS_EX
else build_flat_pool(source, allow_force=True)
)
result = self._run_choice(
pool,
change_every,
source_key=source,
reset_on_source_change=False,
latest_revision=latest_revision,
)
return self._format_result(
result,
latest_revision=latest_revision,
runtime_status=runtime_status,
)
class RuntimePromptRandomChoice(_RuntimeRandomChoiceMixin):
IS_EX = False
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("selected_text", "selected_text_safe")
FUNCTION = "pick"
CATEGORY = CATEGORY
class RuntimePromptRandomChoiceEx(_RuntimeRandomChoiceMixin):
IS_EX = True
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("selected_text", "selected_text_safe")
FUNCTION = "pick"
CATEGORY = CATEGORY
class SafeRandomSeed:
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("seed",)
FUNCTION = "generate"
CATEGORY = CATEGORY
@classmethod
def IS_CHANGED(cls, **kwargs):
return secrets.token_hex(16)
def generate(self):
seed = secrets.randbelow(MAX_SAFE_SEED + 1)
return {
"ui": {
"seed_title": [f"Seed: {seed}"],
"seed_value": [str(seed)],
},
"result": (seed,),
}
NODE_CLASS_MAPPINGS = {
"PromptRandomChoice": PromptRandomChoice,
"PromptRandomChoiceEx": PromptRandomChoiceEx,
"RuntimePromptRandomChoice": RuntimePromptRandomChoice,
"RuntimePromptRandomChoiceEx": RuntimePromptRandomChoiceEx,
"SafeRandomSeed": SafeRandomSeed,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptRandomChoice": "Prompt Random Choice",
"PromptRandomChoiceEx": "Prompt Random Choice Ex",
"RuntimePromptRandomChoice": "Runtime Prompt Random Choice",
"RuntimePromptRandomChoiceEx": "Runtime Prompt Random Choice Ex",
"SafeRandomSeed": "Safe Random Seed",
}