Files
gonzalu-ComfyUI_YFG_Comical/YFGRandomPromptFromFile.py
2026-08-23 21:02:54 -04:00

637 lines
25 KiB
Python

"""
@author: Manny Gonzalez
@title: 🐯 YFG Comical Nodes
@nickname: 🐯 YFG Comical Nodes
@description: Selects a random prompt from a .txt prompt file using true random.org
numbers. Single self-contained node with file browser, history cache,
and multiple selection modes.
Changelog:
1.1.1 Fixed first prompt silently dropped in files starting with a leading
separator line (e.g. "----" on line 1). Strip leading separator
before splitting so all prompts are counted and selectable.
1.1.0 Simplified inputs: removed first_n/last_n/custom_range/n_pool.
range_start/range_end auto-populate from file when selected in UI.
Added last_n_only toggle + last_n_count to restrict pool to newest entries.
1.0.0 Initial release.
"""
from __future__ import annotations
import os
import re
import json
import time
import hashlib
import random
import platform
from pathlib import Path
from typing import Dict, List, Optional, Tuple
NODE_VERSION = "1.1.1"
# ─────────────────────────── file format ──────────────────────────────────────
# Prompt file format:
# positive: text
# negative: text (required field, may be empty)
# name: text (optional, defaults to filename stem)
# Entries separated by one or more hyphens on their own line (any length).
# Leading/trailing separator lines are silently ignored.
_SEPARATOR_RE = re.compile(r'\n\s*-+\s*\n')
_PROMPT_RE = re.compile(
r"^(?:(?:positive:(?P<positive>.*?)|negative:(?P<negative>.*?)|name:(?P<name>.*?))\n*)+$",
re.DOTALL | re.IGNORECASE,
)
# ─────────────────────────── prompt file cache ────────────────────────────────
class _PromptFileCache:
"""Parses and caches prompt files. Re-parses only when file mtime changes."""
_cache: Dict[str, dict] = {}
@classmethod
def load(cls, filepath: str) -> List[Tuple[str, str, str]]:
try:
mtime = os.path.getmtime(filepath)
except OSError:
return []
cached = cls._cache.get(filepath)
if cached and cached["mtime"] == mtime:
return cached["prompts"]
prompts = cls._parse(filepath)
cls._cache[filepath] = {"mtime": mtime, "prompts": prompts}
print(f"[YFG] RandomPromptFromFile: parsed {len(prompts)} prompts from '{Path(filepath).name}'")
return prompts
@classmethod
def _parse(cls, filepath: str) -> List[Tuple[str, str, str]]:
try:
with open(filepath, "r", encoding="utf-8") as f:
data = f.read()
except Exception as e:
print(f"[YFG] RandomPromptFromFile: cannot read '{filepath}': {e}")
return []
# Strip any leading separator line (e.g. "----\n" at position 0).
# The split regex requires \n before hyphens, so a leading separator
# would not be matched — causing the first prompt to be silently lost.
data = re.sub(r'^[ \t]*-+[ \t]*[\r\n]+', '', data)
blocks = _SEPARATOR_RE.split(data)
prompts = []
stem = Path(filepath).stem
for block in blocks:
block = block.strip()
if not block:
continue
m = _PROMPT_RE.search(block)
if m:
positive = (m.group("positive") or "").strip()
negative = (m.group("negative") or "").strip()
name = (m.group("name") or "").strip() or stem
prompts.append((positive, negative, name))
else:
print(f"[YFG] RandomPromptFromFile: skipping unrecognized block in '{Path(filepath).name}'")
return prompts
# ─────────────────────────── file history ─────────────────────────────────────
_FILE_HISTORY_PATH = Path(__file__).parent / "yfg_file_history.json"
_MAX_FILE_HISTORY = 20
class _FileHistory:
@classmethod
def _read(cls) -> list:
try:
if _FILE_HISTORY_PATH.exists():
data = json.loads(_FILE_HISTORY_PATH.read_text(encoding="utf-8"))
if isinstance(data, list):
return [str(d) for d in data if isinstance(d, str) and d.strip()]
except Exception:
pass
return []
@classmethod
def get(cls) -> list:
return cls._read()
@classmethod
def add(cls, filepath: str):
try:
resolved = str(Path(filepath).resolve())
files = cls._read()
if resolved in files:
files.remove(resolved)
files.insert(0, resolved)
files = files[:_MAX_FILE_HISTORY]
_FILE_HISTORY_PATH.write_text(json.dumps(files, indent=2), encoding="utf-8")
except Exception as e:
print(f"[YFG] FileHistory.add error: {e}")
@classmethod
def remove(cls, filepath: str):
try:
resolved = str(Path(filepath).resolve())
files = cls._read()
if resolved in files:
files.remove(resolved)
_FILE_HISTORY_PATH.write_text(json.dumps(files, indent=2), encoding="utf-8")
except Exception as e:
print(f"[YFG] FileHistory.remove error: {e}")
@classmethod
def clear(cls):
try:
_FILE_HISTORY_PATH.write_text("[]", encoding="utf-8")
except Exception as e:
print(f"[YFG] FileHistory.clear error: {e}")
# ─────────────────────────── shuffle bag ──────────────────────────────────────
class _ShuffleBag:
MAX_BAG_SIZE = 200_000
_bags: Dict[str, dict] = {}
@classmethod
def can_use(cls, lo: int, hi: int) -> bool:
return 1 <= (hi - lo + 1) <= cls.MAX_BAG_SIZE
@classmethod
def _get_bag(cls, key: str) -> dict:
if key not in cls._bags:
cls._bags[key] = {"remaining": [], "last_lo": None, "last_hi": None}
return cls._bags[key]
@classmethod
def next_value(cls, bag_key: str, lo: int, hi: int) -> int:
bag = cls._get_bag(bag_key)
if bag["last_lo"] != lo or bag["last_hi"] != hi or not bag["remaining"]:
values = list(range(lo, hi + 1))
random.SystemRandom().shuffle(values)
bag["remaining"] = values
bag["last_lo"] = lo
bag["last_hi"] = hi
return int(bag["remaining"].pop(0))
# ─────────────────────────── uniqueness history ────────────────────────────────
class _UniqueHistory:
_caches: Dict[str, dict] = {}
@classmethod
def _cache(cls, key: str) -> dict:
if key not in cls._caches:
cls._caches[key] = {"map": {}, "order": []}
return cls._caches[key]
@classmethod
def is_duplicate(cls, value: int, scope: str, history_size: int, time_window: int) -> bool:
now = time.time()
c = cls._cache(scope)
cls._prune(c, history_size, time_window)
ts = c["map"].get(value)
if ts is None:
return False
if time_window > 0 and (now - ts) > time_window:
return False
return True
@classmethod
def remember(cls, value: int, scope: str, history_size: int):
c = cls._cache(scope)
c["map"][value] = time.time()
c["order"].append(value)
cls._prune(c, history_size, 0)
@classmethod
def _prune(cls, c: dict, history_size: int, time_window: int):
while len(c["order"]) > history_size:
old = c["order"].pop(0)
c["map"].pop(old, None)
if time_window > 0:
now = time.time()
for v in list(c["order"]):
ts = c["map"].get(v)
if ts and (now - ts) > time_window:
c["order"].remove(v)
c["map"].pop(v, None)
# ─────────────────────────── API routes ───────────────────────────────────────
try:
from server import PromptServer
from aiohttp import web as _aio_web
@PromptServer.instance.routes.get("/yfg/file_history")
async def _yfg_get_file_history(request):
return _aio_web.json_response(_FileHistory.get())
@PromptServer.instance.routes.post("/yfg/file_history/add")
async def _yfg_add_file_history(request):
data = await request.json()
filepath = data.get("filepath", "").strip()
if filepath:
_FileHistory.add(filepath)
return _aio_web.json_response({"ok": True, "history": _FileHistory.get()})
@PromptServer.instance.routes.post("/yfg/file_history/remove")
async def _yfg_remove_file_history(request):
data = await request.json()
filepath = data.get("filepath", "").strip()
if filepath:
_FileHistory.remove(filepath)
return _aio_web.json_response({"ok": True, "history": _FileHistory.get()})
@PromptServer.instance.routes.post("/yfg/file_history/clear")
async def _yfg_clear_file_history(request):
_FileHistory.clear()
return _aio_web.json_response({"ok": True})
@PromptServer.instance.routes.get("/yfg/file_browse")
async def _yfg_browse_files(request):
"""Navigator that returns both subdirectories and .txt files."""
path = request.query.get("path", "").strip()
show_hidden = request.query.get("show_hidden", "false").lower() == "true"
try:
if not path:
if platform.system() == "Windows":
import string
from ctypes import windll
drives, bitmask = [], windll.kernel32.GetLogicalDrives()
for letter in string.ascii_uppercase:
if bitmask & 1:
drives.append({"name": f"{letter}:\\", "path": f"{letter}:\\", "type": "dir"})
bitmask >>= 1
return _aio_web.json_response({
"path": "", "parent": None, "name": "Drives",
"entries": drives, "is_root": True,
})
else:
candidates = ["/", str(Path.home()), "/mnt", "/media", "/Volumes", "/data"]
roots = [{"name": c, "path": c, "type": "dir"} for c in candidates if Path(c).exists()]
return _aio_web.json_response({
"path": "", "parent": None, "name": "Filesystem",
"entries": roots, "is_root": True,
})
p = Path(path)
if not p.exists() or not p.is_dir():
return _aio_web.json_response({"error": f"Not a valid directory: {path}"}, status=400)
dirs, files = [], []
try:
for item in sorted(p.iterdir(), key=lambda x: x.name.lower()):
if not show_hidden and item.name.startswith("."):
continue
if item.is_dir():
dirs.append({"name": item.name, "path": str(item), "type": "dir"})
elif item.is_file() and item.suffix.lower() == ".txt":
files.append({"name": item.name, "path": str(item), "type": "file"})
except PermissionError:
pass
parent = str(p.parent) if str(p.parent) != str(p) else None
return _aio_web.json_response({
"path": str(p),
"parent": parent,
"name": p.name or str(p),
"entries": dirs + files,
"is_root": False,
})
except Exception as e:
return _aio_web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.get("/yfg/prompt_count")
async def _yfg_prompt_count(request):
"""Returns total valid prompt count for a file (uses parse cache)."""
filepath = request.query.get("path", "").strip()
if not filepath or not Path(filepath).exists():
return _aio_web.json_response({"count": 0, "error": "File not found"})
prompts = _PromptFileCache.load(filepath)
return _aio_web.json_response({"count": len(prompts)})
print("[YFG] RandomPromptFromFile: API routes registered "
"(/yfg/file_history, /yfg/file_browse, /yfg/prompt_count)")
except Exception as _api_err:
print(f"[YFG] RandomPromptFromFile: Could not register API routes — {_api_err}")
# ─────────────────────────── random.org helpers ───────────────────────────────
def _load_api_key() -> Optional[str]:
ev = os.environ.get("RANDOM_ORG_API_KEY", "").strip()
if ev:
return ev
fp = Path(__file__).with_name("random_org_api_key.json")
if fp.exists():
try:
return json.loads(fp.read_text())["api_key"].strip()
except Exception:
pass
return None
def _random_org_int(api_key: str, lo: int, hi: int) -> Optional[int]:
try:
import requests
payload = {
"jsonrpc": "2.0", "method": "generateIntegers",
"params": {"apiKey": api_key, "n": 1, "min": lo, "max": hi,
"replacement": True, "base": 10},
"id": 1,
}
r = requests.post(
"https://api.random.org/json-rpc/2/invoke",
headers={"Content-Type": "application/json"},
data=json.dumps(payload), timeout=10,
)
if r.status_code == 200:
return int(r.json()["result"]["random"]["data"][0])
except Exception:
pass
return None
def _rand_int(lo: int, hi: int, source: str) -> int:
if source == "local":
return random.randint(lo, hi)
api_key = _load_api_key()
if api_key and source in ("random_org", "auto"):
result = _random_org_int(api_key, lo, hi)
if result is not None:
return result
return random.randint(lo, hi)
# ─────────────────────────── main node ────────────────────────────────────────
class YFGRandomPromptFromFile:
"""
YFG Random Prompt From File
Selects a random prompt from a .txt prompt file using true random.org numbers.
Single self-contained node — no additional nodes required.
range_start / range_end auto-populate when a file is selected in the UI.
last_n_only toggle restricts the pool to the last N entries in the file
(useful when you append new prompts to the bottom and want fresh picks).
"""
DESCRIPTION = (
f"YFG Random Prompt From File (v{NODE_VERSION})\n\n"
"Selects a random prompt from a .txt prompt file.\n\n"
"File format:\n"
" positive: your positive prompt\n"
" negative: your negative prompt (empty is fine)\n"
" name: optional label (defaults to filename)\n"
" ---- (any number of hyphens as separator)\n\n"
"range_start / range_end auto-fill when a file is selected.\n"
"Toggle last_n_only to restrict picks to the newest entries.\n"
"INDEX auto-syncs after every run — switch to by_index with no typing.\n"
)
OUTPUT_TOOLTIPS = (
"Positive prompt text.",
"Negative prompt text (empty if not in file).",
"Prompt name — from 'name:' field or filename if omitted.",
"0-based index of the selected prompt. Auto-syncs to INDEX widget.",
"Index of the previously selected prompt this session.",
"Total number of valid prompts found in the file.",
"Full path to the prompt file.",
"Filename of the prompt file (basename only).",
"Effective range_start used for this run.",
"Effective range_end used for this run.",
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt_file": ("STRING", {
"multiline": False,
"placeholder": "Path to .txt prompt file — use 📄 Browse or 🕐 Recent",
"tooltip": "Full path to a .txt prompt file.",
}),
"selection_mode": (["random", "by_index"], {
"default": "random",
"tooltip": "random picks from range_start..range_end. by_index uses INDEX directly.",
}),
"index": ("INT", {
"default": 0,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"tooltip": "Used by by_index mode. Auto-syncs after every run.",
}),
"range_start": ("INT", {
"default": 0,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"tooltip": "First prompt index to include in random pool (auto-fills to 0 when file selected).",
}),
"range_end": ("INT", {
"default": 0,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"tooltip": "Last prompt index to include in random pool (auto-fills to total-1 when file selected). 0 = last prompt.",
}),
"last_n_only": ("BOOLEAN", {
"default": False,
"tooltip": "When ON, restricts the random pool to the last N entries only (newest prompts).",
}),
"last_n_count": ("INT", {
"default": 100,
"min": 1,
"max": 0xFFFFFFFFFFFFFFFF,
"tooltip": "How many entries from the end of the file to pick from (only active when last_n_only is ON).",
}),
"random_source": (["auto", "local", "random_org"], {
"default": "auto",
"tooltip": "auto uses random.org if API key exists; else local PRNG.",
}),
"ensure_unique": ("BOOLEAN", {
"default": True,
"tooltip": "Avoid repeating prompts within history_size / time_window_sec.",
}),
"history_size": ("INT", {
"default": 100,
"min": 1,
"max": 100000,
"tooltip": "How many recent indices to track for uniqueness.",
}),
"time_window_sec": ("INT", {
"default": 0,
"min": 0,
"max": 86400,
"tooltip": "If >0, forget uniqueness entries older than this many seconds.",
}),
"retry_limit": ("INT", {
"default": 20,
"min": 1,
"max": 1000,
}),
"use_shuffle_bag": ("BOOLEAN", {
"default": True,
"tooltip": "Cycle through all prompts before repeating, then reshuffle.",
}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "INT", "INT", "INT", "STRING", "STRING", "INT", "INT")
RETURN_NAMES = ("positive", "negative", "name",
"index_current", "index_previous", "total_count",
"file_path", "file_name", "range_start", "range_end")
FUNCTION = "load_prompt"
CATEGORY = "🐯 YFG/📝 Prompts"
def __init__(self):
self._prev_index: int = -1
def load_prompt(
self,
prompt_file: str,
selection_mode: str,
index: int,
range_start: int,
range_end: int,
last_n_only: bool,
last_n_count: int,
random_source: str,
ensure_unique: bool,
history_size: int,
time_window_sec: int,
retry_limit: int,
use_shuffle_bag: bool,
):
if not prompt_file or not os.path.isfile(prompt_file):
raise Exception(
f"[YFG] RandomPromptFromFile: file not found: '{prompt_file}'"
)
prompts = _PromptFileCache.load(prompt_file)
if not prompts:
raise Exception(
f"[YFG] RandomPromptFromFile: no valid prompts found in '{prompt_file}'"
)
total = len(prompts)
prev_idx = self._prev_index
if selection_mode == "by_index":
idx = max(0, min(int(index), total - 1))
eff_lo = 0
eff_hi = total - 1
else:
# Resolve range
eff_lo = max(0, min(int(range_start), total - 1))
eff_hi = int(range_end) if int(range_end) > 0 else total - 1
eff_hi = max(eff_lo, min(eff_hi, total - 1))
# last_n_only overrides lo to restrict pool to newest entries
if last_n_only:
eff_lo = max(eff_lo, eff_hi - int(last_n_count) + 1)
scope = f"file::{Path(prompt_file).resolve()}"
idx = self._pick(
eff_lo, eff_hi, scope, random_source, ensure_unique,
history_size, time_window_sec, retry_limit, use_shuffle_bag,
)
positive, negative, name = prompts[idx]
self._prev_index = idx
_FileHistory.add(prompt_file)
print(
f"[YFG] RandomPromptFromFile v{NODE_VERSION}: "
f"idx={idx}/{total - 1} name={name}"
)
result = (
positive, negative, name,
int(idx), int(prev_idx), int(total),
str(prompt_file),
Path(prompt_file).name,
int(eff_lo),
int(eff_hi),
)
return {
"ui": {
"yfg_pf_index_current": (int(idx),),
"yfg_pf_index_previous": (int(prev_idx),),
"yfg_pf_total_count": (int(total),),
"yfg_pf_range_start": (int(eff_lo),),
"yfg_pf_range_end": (int(eff_hi),),
},
"result": result,
}
@staticmethod
def _pick(
lo: int, hi: int, scope: str,
random_source: str, ensure_unique: bool,
history_size: int, time_window: int,
retry_limit: int, use_shuffle_bag: bool,
) -> int:
if lo == hi:
return lo
if ensure_unique and use_shuffle_bag and _ShuffleBag.can_use(lo, hi):
bag_key = f"bag::{scope}::{lo}::{hi}"
candidate = lo
for _ in range(max(1, retry_limit)):
candidate = _ShuffleBag.next_value(bag_key, lo, hi)
if not _UniqueHistory.is_duplicate(candidate, scope, history_size, time_window):
_UniqueHistory.remember(candidate, scope, history_size)
return candidate
_UniqueHistory.remember(candidate, scope, history_size)
return candidate
candidate = lo
for _ in range(max(1, retry_limit)):
candidate = _rand_int(lo, hi, random_source)
if not ensure_unique or not _UniqueHistory.is_duplicate(
candidate, scope, history_size, time_window):
if ensure_unique:
_UniqueHistory.remember(candidate, scope, history_size)
return candidate
print("[YFG] RandomPromptFromFile: retry limit reached; using last candidate.")
if ensure_unique:
_UniqueHistory.remember(candidate, scope, history_size)
return candidate
@classmethod
def IS_CHANGED(
cls,
prompt_file, selection_mode, index, range_start, range_end,
last_n_only, last_n_count, random_source, ensure_unique,
history_size, time_window_sec, retry_limit, use_shuffle_bag, **kwargs,
):
if selection_mode == "random" or ensure_unique:
return float("NaN")
m = hashlib.sha256()
for v in (prompt_file, selection_mode, index):
m.update(str(v).encode())
m.update(b"|")
return m.hexdigest()
# ─────────────────────────── registration ─────────────────────────────────────
# Add to __init__.py:
# from .YFGRandomPromptFromFile import YFGRandomPromptFromFile
# NODE_CLASS_MAPPINGS["YFGRandomPromptFromFile_node"] = YFGRandomPromptFromFile
# NODE_DISPLAY_NAME_MAPPINGS["YFGRandomPromptFromFile_node"] = "🐯 YFG Random Prompt From File"
NODE_CLASS_MAPPINGS = {
"YFGRandomPromptFromFile_node": YFGRandomPromptFromFile,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"YFGRandomPromptFromFile_node": "🐯 YFG Random Prompt From File",
}