428 lines
18 KiB
Python
428 lines
18 KiB
Python
"""
|
|
@author: Manny Gonzalez
|
|
@title: 🐯 YFG Comical Nodes
|
|
@nickname: 🐯 YFG Comical Nodes
|
|
@description: Utility custom nodes for special effects, image manipulation and quality of life tools.
|
|
"""
|
|
|
|
## Based on original idea by Jordan Thompson (WASasquatch) https://github.com/WASasquatch/was-node-suite-comfyui ##
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to
|
|
# deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
# and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
# THE SOFTWARE.
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import json
|
|
import hashlib
|
|
import requests
|
|
from typing import List, Dict, Any, Tuple, Optional
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# API KEY STORAGE (no UI field)
|
|
# -----------------------------------------------------------------------------
|
|
|
|
NODE_VERSION = "2.0.1" # bump however you want; this node file previously didn't version explicitly
|
|
API_KEY_FILENAME = "random_org_api_key.json"
|
|
|
|
|
|
def _load_api_key() -> Optional[str]:
|
|
# 1) Env var wins for easy local development
|
|
env_key = os.getenv("RANDOM_ORG_API_KEY")
|
|
if env_key and env_key.strip():
|
|
return env_key.strip()
|
|
|
|
# 2) Local JSON file next to this script
|
|
here = os.path.dirname(os.path.realpath(__file__))
|
|
key_path = os.path.join(here, API_KEY_FILENAME)
|
|
try:
|
|
with open(key_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
key = (data or {}).get("api_key", "").strip()
|
|
return key if key else None
|
|
except FileNotFoundError:
|
|
print(f"RandomOrgV2: API key file not found: {key_path}")
|
|
except json.JSONDecodeError as e:
|
|
print(f"RandomOrgV2: Failed to parse {API_KEY_FILENAME}: {e}")
|
|
except Exception as e:
|
|
print(f"RandomOrgV2: Unexpected error reading key file: {e}")
|
|
return None
|
|
|
|
|
|
class _ShuffleBag:
|
|
"""
|
|
Shuffle-bag (sampling without replacement).
|
|
For each bag_key we maintain a shuffled list of remaining values.
|
|
When empty or when range changes, refill + reshuffle.
|
|
"""
|
|
# bag_key -> {"remaining": [int,...], "last_size": int}
|
|
_bags: Dict[str, Dict[str, Any]] = {}
|
|
|
|
# Guardrail: don't build a bag bigger than this many items.
|
|
# If a user sets min/max to a massive span, we fall back to history de-dup.
|
|
MAX_BAG_SIZE = 200_000
|
|
|
|
@classmethod
|
|
def can_use(cls, lo: int, hi: int) -> bool:
|
|
n = (hi - lo + 1)
|
|
return 1 <= n <= cls.MAX_BAG_SIZE
|
|
|
|
@classmethod
|
|
def _get_bag(cls, bag_key: str) -> Dict[str, Any]:
|
|
if bag_key not in cls._bags:
|
|
cls._bags[bag_key] = {"remaining": [], "last_size": -1}
|
|
return cls._bags[bag_key]
|
|
|
|
@classmethod
|
|
def next_value(cls, bag_key: str, lo: int, hi: int) -> int:
|
|
bag = cls._get_bag(bag_key)
|
|
size = hi - lo + 1
|
|
|
|
if bag["last_size"] != size or not bag["remaining"]:
|
|
values = list(range(lo, hi + 1))
|
|
# Local cryptographic shuffle; avoids hammering random.org
|
|
import random
|
|
random.SystemRandom().shuffle(values)
|
|
bag["remaining"] = values
|
|
bag["last_size"] = size
|
|
|
|
return int(bag["remaining"].pop(0))
|
|
|
|
|
|
class RandomOrgV2TrueRandomNumber:
|
|
"""
|
|
True Random Number Generator (V2) via random.org.
|
|
"""
|
|
|
|
DESCRIPTION = (
|
|
f"Random.org True Random Number (V2) (v{NODE_VERSION})\n\n"
|
|
"Generates a true random integer using random.org.\n\n"
|
|
"Uniqueness options:\n"
|
|
" • ensure_unique=true + use_shuffle_bag=true: uses a shuffle-bag (no repeats until the range is exhausted, then reshuffles).\n"
|
|
" • If the range is very large, the node automatically falls back to history-based de-dup to avoid huge memory use.\n\n"
|
|
"API Key:\n"
|
|
" • Set env var RANDOM_ORG_API_KEY, or create random_org_api_key.json next to this file: {\"api_key\":\"...\"}\n"
|
|
)
|
|
|
|
# Output hover tips (one per RETURN_TYPES/RETURN_NAMES order)
|
|
OUTPUT_TOOLTIPS = (
|
|
"ComfyUI NUMBER output (typically behaves like a float).",
|
|
"Same value as a Python float.",
|
|
"Same value as a Python int.",
|
|
)
|
|
|
|
# ---- Class-level caches (persist for the lifetime of the Python process) ----
|
|
# Global cache: value -> last_timestamp
|
|
_global_map = {}
|
|
_global_order = [] # FIFO
|
|
|
|
# Per-range caches: key (min,max) -> {"map": {value: t}, "order": [values FIFO]}
|
|
_range_caches = {}
|
|
|
|
def __init__(self):
|
|
pass
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
# Include both tooltip+description; different frontends have historically used one or the other.
|
|
return {
|
|
"required": {
|
|
"minimum": ("FLOAT", {
|
|
"default": 0,
|
|
"min": -9223372036854775808,
|
|
"max": 9223372036854775807,
|
|
"tooltip": "Lower bound (inclusive). Floats are converted to integers via int().",
|
|
"description": "Lower bound (inclusive). Floats are converted to integers via int().",
|
|
}),
|
|
"maximum": ("FLOAT", {
|
|
"default": 10,
|
|
"min": -9223372036854775808,
|
|
"max": 9223372036854775807,
|
|
"tooltip": "Upper bound (inclusive). Floats are converted to integers via int().",
|
|
"description": "Upper bound (inclusive). Floats are converted to integers via int().",
|
|
}),
|
|
"mode": (["random", "fixed"], {
|
|
"default": "random",
|
|
"tooltip": "random: call random.org. fixed: return minimum deterministically (cacheable).",
|
|
"description": "random: call random.org. fixed: return minimum deterministically (cacheable).",
|
|
}),
|
|
},
|
|
"optional": {
|
|
# Uniqueness controls
|
|
"ensure_unique": ("BOOLEAN", {
|
|
"default": True, # ✅ requested
|
|
"label": "Avoid repeats?",
|
|
"tooltip": "If true, avoids repeats using shuffle-bag (preferred) or history de-dup (fallback).",
|
|
"description": "If true, avoids repeats using shuffle-bag (preferred) or history de-dup (fallback).",
|
|
}),
|
|
"unique_scope": (["range", "global"], {
|
|
"default": "range",
|
|
"label": "Unique scope",
|
|
"tooltip": "range: uniqueness tracked per (minimum,maximum). global: shared across all ranges.",
|
|
"description": "range: uniqueness tracked per (minimum,maximum). global: shared across all ranges.",
|
|
}),
|
|
"history_size": ("INT", {
|
|
"default": 100,
|
|
"min": 1,
|
|
"max": 100000,
|
|
"label": "Unique history size",
|
|
"tooltip": "Used by history-based de-dup (and as extra guard when global scope is enabled).",
|
|
"description": "Used by history-based de-dup (and as extra guard when global scope is enabled).",
|
|
}),
|
|
"time_window_sec": ("INT", {
|
|
"default": 0,
|
|
"min": 0,
|
|
"max": 86400,
|
|
"label": "Unique time window (sec, 0=off)",
|
|
"tooltip": "If >0, values older than this many seconds are forgotten for de-dup checks.",
|
|
"description": "If >0, values older than this many seconds are forgotten for de-dup checks.",
|
|
}),
|
|
"retry_limit": ("INT", {
|
|
"default": 20,
|
|
"min": 0,
|
|
"max": 1000,
|
|
"label": "Retry attempts on duplicate",
|
|
"tooltip": "How many times to try finding a non-duplicate when using history-based de-dup (or global checks).",
|
|
"description": "How many times to try finding a non-duplicate when using history-based de-dup (or global checks).",
|
|
}),
|
|
"use_shuffle_bag": ("BOOLEAN", {
|
|
"default": True,
|
|
"label": "Use shuffle-bag (no repeats until exhausted)",
|
|
"tooltip": f"If true and range size <= {_ShuffleBag.MAX_BAG_SIZE:,}, use shuffle-bag. Otherwise fall back.",
|
|
"description": f"If true and range size <= {_ShuffleBag.MAX_BAG_SIZE:,}, use shuffle-bag. Otherwise fall back.",
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("NUMBER", "FLOAT", "INT")
|
|
RETURN_NAMES = ("number", "float", "int")
|
|
FUNCTION = "return_true_random_number"
|
|
CATEGORY = "🐯 YFG/🔢 Numbers"
|
|
|
|
def return_true_random_number(
|
|
self,
|
|
minimum: float,
|
|
maximum: float,
|
|
mode: str = "random",
|
|
ensure_unique: bool = True,
|
|
unique_scope: str = "range",
|
|
history_size: int = 100,
|
|
time_window_sec: int = 0,
|
|
retry_limit: int = 20,
|
|
use_shuffle_bag: bool = True,
|
|
):
|
|
api_key = _load_api_key()
|
|
if not api_key:
|
|
print("RandomOrgV2: No API key available. Create random_org_api_key.json or set RANDOM_ORG_API_KEY.")
|
|
number = 0
|
|
return {"ui": {"yfg_number": (number,), "yfg_float": (float(number),), "yfg_int": (int(number),)}, "result": (number, float(number), int(number))}
|
|
|
|
# Normalize and validate bounds
|
|
lo = int(minimum)
|
|
hi = int(maximum)
|
|
if hi < lo:
|
|
print(f"RandomOrgV2: Swapping min/max since maximum < minimum ({hi} < {lo}).")
|
|
lo, hi = hi, lo
|
|
|
|
# If mode is fixed, always return the minimum (deterministic)
|
|
if mode == "fixed":
|
|
number = lo
|
|
return {"ui": {"yfg_number": (number,), "yfg_float": (float(number),), "yfg_int": (int(number),)}, "result": (number, float(number), int(number))}
|
|
|
|
span = hi - lo + 1
|
|
|
|
# Fast path: uniqueness off OR trivial range
|
|
if (not ensure_unique) or span <= 1:
|
|
nums = self._get_random_integers(api_key=api_key, amount=1, minimum=lo, maximum=hi)
|
|
number = nums[0]
|
|
return {"ui": {"yfg_number": (number,), "yfg_float": (float(number),), "yfg_int": (int(number),)}, "result": (number, float(number), int(number))}
|
|
|
|
# ----------------------------
|
|
# Uniqueness-enabled path
|
|
# ----------------------------
|
|
|
|
# Prefer shuffle-bag when range size is reasonable.
|
|
# This guarantees "no repeats until exhausted".
|
|
if use_shuffle_bag and _ShuffleBag.can_use(lo, hi):
|
|
# Bag key incorporates scope + range
|
|
bag_key = f"{unique_scope}::{lo}::{hi}"
|
|
|
|
candidate = _ShuffleBag.next_value(bag_key=bag_key, lo=lo, hi=hi)
|
|
|
|
# If unique_scope is global, also honor global history rules to avoid collisions
|
|
# across different ranges that share the same numeric value.
|
|
if unique_scope == "global":
|
|
range_key = (lo, hi)
|
|
tries = 0
|
|
max_tries = max(1, int(retry_limit))
|
|
while tries < max_tries and self._is_duplicate(candidate, range_key, unique_scope, history_size, time_window_sec):
|
|
candidate = _ShuffleBag.next_value(bag_key=bag_key, lo=lo, hi=hi)
|
|
tries += 1
|
|
|
|
self._remember(candidate, (lo, hi), unique_scope, history_size)
|
|
return {"ui": {"yfg_number": (candidate,), "yfg_float": (float(candidate),), "yfg_int": (int(candidate),)}, "result": (candidate, float(candidate), int(candidate))}
|
|
|
|
# Fallback: history-based de-dup (kept for very large ranges)
|
|
key = (lo, hi)
|
|
candidate = lo # init for safety
|
|
for _ in range(max(1, int(retry_limit))):
|
|
nums = self._get_random_integers(api_key=api_key, amount=1, minimum=lo, maximum=hi)
|
|
candidate = nums[0]
|
|
if not self._is_duplicate(candidate, key, unique_scope, history_size, time_window_sec):
|
|
self._remember(candidate, key, unique_scope, history_size)
|
|
return {"ui": {"yfg_number": (candidate,), "yfg_float": (float(candidate),), "yfg_int": (int(candidate),)}, "result": (candidate, float(candidate), int(candidate))}
|
|
|
|
print("RandomOrgV2: Retry limit reached while avoiding duplicates; emitting last value.")
|
|
self._remember(candidate, key, unique_scope, history_size)
|
|
return {"ui": {"yfg_number": (candidate,), "yfg_float": (float(candidate),), "yfg_int": (int(candidate),)}, "result": (candidate, float(candidate), int(candidate))}
|
|
|
|
# --- Internal helpers ---
|
|
def _get_random_integers(self, api_key: str, amount: int, minimum: int, maximum: int) -> List[int]:
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"method": "generateIntegers",
|
|
"params": {
|
|
"apiKey": api_key,
|
|
"n": int(amount),
|
|
"min": int(minimum),
|
|
"max": int(maximum),
|
|
"replacement": True,
|
|
"base": 10
|
|
},
|
|
"id": 1
|
|
}
|
|
|
|
try:
|
|
resp = requests.post(
|
|
"https://api.random.org/json-rpc/2/invoke",
|
|
headers={"Content-Type": "application/json"},
|
|
data=json.dumps(payload),
|
|
timeout=20,
|
|
)
|
|
except requests.RequestException as e:
|
|
print(f"RandomOrgV2: Network error contacting random.org: {e}")
|
|
return [0]
|
|
|
|
if resp.status_code != 200:
|
|
print(f"RandomOrgV2: HTTP {resp.status_code} from random.org")
|
|
return [0]
|
|
|
|
try:
|
|
data = resp.json()
|
|
except ValueError:
|
|
print("RandomOrgV2: Failed to decode JSON from random.org response")
|
|
return [0]
|
|
|
|
if "error" in data:
|
|
print(f"RandomOrgV2: API error: {data['error']}")
|
|
return [0]
|
|
|
|
try:
|
|
return data["result"]["random"]["data"]
|
|
except Exception as e:
|
|
print(f"RandomOrgV2: Unexpected response format: {e}")
|
|
return [0]
|
|
|
|
@staticmethod
|
|
def _now() -> float:
|
|
try:
|
|
import time
|
|
return time.time()
|
|
except Exception:
|
|
return 0.0
|
|
|
|
def _get_range_cache(self, key: Tuple[int, int]):
|
|
cache = self._range_caches.get(key)
|
|
if cache is None:
|
|
cache = {"map": {}, "order": []}
|
|
self._range_caches[key] = cache
|
|
return cache
|
|
|
|
def _prune(self, ordered_list, value_map, history_size, time_window_sec):
|
|
while len(ordered_list) > history_size:
|
|
oldest = ordered_list.pop(0)
|
|
value_map.pop(oldest, None)
|
|
if time_window_sec and time_window_sec > 0:
|
|
now = self._now()
|
|
for v in list(ordered_list):
|
|
ts = value_map.get(v)
|
|
if ts is None:
|
|
continue
|
|
if now - ts > time_window_sec:
|
|
ordered_list.remove(v)
|
|
value_map.pop(v, None)
|
|
|
|
def _is_duplicate(self, value: int, range_key: Tuple[int, int], scope: str, history_size: int, time_window_sec: int) -> bool:
|
|
now = self._now()
|
|
if scope == "global":
|
|
self._prune(self._global_order, self._global_map, history_size, time_window_sec)
|
|
ts = self._global_map.get(value)
|
|
if ts is None:
|
|
return False
|
|
if time_window_sec and time_window_sec > 0 and (now - ts) > time_window_sec:
|
|
return False
|
|
return True
|
|
else:
|
|
cache = self._get_range_cache(range_key)
|
|
self._prune(cache["order"], cache["map"], history_size, time_window_sec)
|
|
ts = cache["map"].get(value)
|
|
if ts is None:
|
|
return False
|
|
if time_window_sec and time_window_sec > 0 and (now - ts) > time_window_sec:
|
|
return False
|
|
return True
|
|
|
|
def _remember(self, value: int, range_key: Tuple[int, int], scope: str, history_size: int):
|
|
now = self._now()
|
|
if scope == "global":
|
|
self._global_map[value] = now
|
|
self._global_order.append(value)
|
|
self._prune(self._global_order, self._global_map, history_size, 0)
|
|
else:
|
|
cache = self._get_range_cache(range_key)
|
|
cache["map"][value] = now
|
|
cache["order"].append(value)
|
|
self._prune(cache["order"], cache["map"], history_size, 0)
|
|
|
|
@classmethod
|
|
def IS_CHANGED(
|
|
cls,
|
|
mode: str,
|
|
minimum: float,
|
|
maximum: float,
|
|
ensure_unique: bool = True,
|
|
unique_scope: str = "range",
|
|
history_size: int = 100,
|
|
time_window_sec: int = 0,
|
|
retry_limit: int = 20,
|
|
use_shuffle_bag: bool = True,
|
|
**kwargs
|
|
):
|
|
# If mode is 'fixed', return a stable hash so ComfyUI can cache the node's output.
|
|
if mode == "fixed":
|
|
m = hashlib.sha256()
|
|
m.update(
|
|
f"{mode}|{minimum}|{maximum}|{ensure_unique}|{unique_scope}|{history_size}|"
|
|
f"{time_window_sec}|{retry_limit}|{use_shuffle_bag}".encode("utf-8")
|
|
)
|
|
return m.hexdigest()
|
|
return float("nan")
|
|
|
|
|
|
# --- ComfyUI registration ---
|
|
NODE_CLASS_MAPPINGS = {
|
|
"RandomOrgV2TrueRandomNumber": RandomOrgV2TrueRandomNumber,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"RandomOrgV2TrueRandomNumber": "Random.org True Random Number (V2)",
|
|
}
|