Files
gonzalu-ComfyUI_YFG_Comical/RandomOrgV2.py
T
Manny Gonzalez 17054a100e Added RandomOrgV2 Node
Added Example API JSON file
Updated README.md
Updated pyproject.toml file
2025-09-28 22:56:14 -04:00

293 lines
12 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
# -----------------------------------------------------------------------------
# API KEY STORAGE (no UI field)
# -----------------------------------------------------------------------------
# The node reads your Random.org API key from a local JSON file that lives
# next to this Python file. This prevents the key from being embedded in
# workflow JSON or image metadata.
#
# File name: random_org_api_key.json
# Format:
# {
# "api_key": "YOUR_API_KEY_HERE"
# }
#
# You can also set an environment variable RANDOM_ORG_API_KEY to override.
# The node will NEVER write or overwrite the key file.
# -----------------------------------------------------------------------------
API_KEY_FILENAME = "random_org_api_key.json"
def _load_api_key() -> str | None:
# 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 RandomOrgV2TrueRandomNumber:
"""True Random Number Generator (V2) via random.org
Outputs three ports to maximize compatibility with downstream nodes:
- NUMBER (ComfyUI generic numeric)
- FLOAT (Python float)
- INT (Python int)
This node does NOT expose the API key in the UI. Provide the key via:
- Environment variable RANDOM_ORG_API_KEY, or
- A JSON file named 'random_org_api_key.json' next to this file.
Optional de-duplication: enable `ensure_unique` to avoid emitting a value
that was recently produced. You can scope uniqueness to the current range
(min/max) or globally, and control history size and/or a time window.
"""
# ---- Class-level caches (persist for the lifetime of the Python process) ----
# Global cache: value -> last_timestamp
_global_map = {}
_global_order = [] # simple list as FIFO; we prune when exceeding size
# Per-range caches: key (min,max) -> {"map": {value: t}, "order": [values in FIFO]}
_range_caches = {}
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"minimum": ("FLOAT", {"default": 0, "min": -9223372036854775808, "max": 9223372036854775807}),
"maximum": ("FLOAT", {"default": 10, "min": -9223372036854775808, "max": 9223372036854775807}),
"mode": (["random", "fixed"],),
},
"optional": {
# Uniqueness controls (OFF by default to preserve exact random.org behavior)
"ensure_unique": ("BOOLEAN", {"default": False, "label": "Avoid repeats?"}),
"unique_scope": (["range", "global"], {"default": "range", "label": "Unique scope"}),
"history_size": ("INT", {"default": 100, "min": 1, "max": 100000, "label": "Unique history size"}),
"time_window_sec": ("INT", {"default": 0, "min": 0, "max": 86400, "label": "Unique time window (sec, 0=off)"}),
"retry_limit": ("INT", {"default": 20, "min": 0, "max": 1000, "label": "Retry attempts on duplicate"}),
}
}
RETURN_TYPES = ("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 = False, unique_scope: str = "range",
history_size: int = 100, time_window_sec: int = 0, retry_limit: int = 20):
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 (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
# Fast path (no uniqueness requested)
if not ensure_unique or (hi - lo + 1) <= 1:
nums = self._get_random_integers(api_key=api_key, amount=1, minimum=lo, maximum=hi)
number = nums[0]
return (number, float(number), int(number))
# Uniqueness-enabled path
key = (lo, hi)
for _ in range(max(1, 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 (candidate, float(candidate), int(candidate))
# If we reach here, we failed to find a unique value within retry limit; emit last candidate
print("RandomOrgV2: Retry limit reached while avoiding duplicates; emitting last value.")
self._remember(candidate, key, unique_scope, history_size)
return (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]
# random.org error response handling
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():
try:
import time
return time.time()
except Exception:
return 0.0
def _get_range_cache(self, key):
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):
# Size-based pruning
while len(ordered_list) > history_size:
oldest = ordered_list.pop(0)
value_map.pop(oldest, None)
# Time-based pruning (if enabled)
if time_window_sec and time_window_sec > 0:
now = self._now()
# Iterate a copy to safely remove while iterating
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, range_key, scope, history_size, time_window_sec):
now = self._now()
if scope == "global":
# Time-based prune first, then test
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: # scope == 'range'
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, range_key, scope, history_size):
now = self._now()
if scope == "global":
# record
self._global_map[value] = now
self._global_order.append(value)
# prune on size only here; time pruning happens on check
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 = False,
unique_scope: str = "range", history_size: int = 100, time_window_sec: int = 0,
retry_limit: int = 20, **kwargs):
"""If mode is 'fixed', return a stable hash so ComfyUI can cache the node's output.
We avoid hashing any secret material; include only configuration.
"""
if mode == "fixed":
m = hashlib.sha256()
m.update(f"{mode}|{minimum}|{maximum}|{ensure_unique}|{unique_scope}|{history_size}|{time_window_sec}|{retry_limit}".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)",
}