Files

184 lines
6.2 KiB
Python

import json
import random
import re
from pathlib import Path
from .ratios import (
ALL_RATIOS, ALL_RATIOS_LABELED,
LANDSCAPE_RATIOS, PORTRAIT_RATIOS,
RATIO_SET, unlabel,
)
_KEYWORDS_FILE = Path(__file__).parent / "keywords.json"
# Detection modes
MODE_OFF = "Off"
MODE_AR_ONLY = "AR only"
MODE_LIMITED = "Limited keywords"
MODE_ALL = "All keywords"
MODES = [MODE_OFF, MODE_AR_ONLY, MODE_LIMITED, MODE_ALL]
_SPECIAL_RATIO_POOLS = {
"$LANDSCAPE": LANDSCAPE_RATIOS,
"$PORTRAIT": PORTRAIT_RATIOS,
}
# Each rule: (label, compiled_regex, pool, weights, min_mode)
_keyword_rules = []
def _words_to_regex(words):
"""Build a case-insensitive pattern from a list of words/phrases."""
parts = []
for w in words:
# Split phrase into tokens, escape each, join with \s+ for flexible spacing
tokens = [re.escape(t) for t in w.split()]
parts.append(r'\b' + r'\s+'.join(tokens) + r'\b')
return re.compile('|'.join(parts), re.I)
def _load_keywords():
global _keyword_rules
try:
with open(_KEYWORDS_FILE, encoding="utf-8") as f:
data = json.load(f)
except Exception:
data = []
rules = []
for entry in data:
words = entry.get("words", [])
raw_ratios = entry.get("ratios", [])
weights = entry.get("weights") or None
category = entry.get("category", "all")
if isinstance(raw_ratios, str):
pool = _SPECIAL_RATIO_POOLS.get(raw_ratios, [])
else:
pool = [r for r in raw_ratios if r in RATIO_SET]
min_mode = MODE_LIMITED if category == "limited" else MODE_ALL
rules.append((entry.get("label", ""), _words_to_regex(words), pool, weights, min_mode))
_keyword_rules = rules
_load_keywords()
def _detect(prompt: str, mode: str):
"""Returns (fixed_ratio_or_None, label_or_None, pool_or_None, weights_or_None)."""
if mode == MODE_OFF:
return None, None, None, None
# Explicit ratio written in the prompt (e.g. "16:9") — always active
for w, h in re.findall(r'\b(\d+):(\d+)\b', prompt):
candidate = f"{w}:{h}"
if candidate in RATIO_SET:
return candidate, "from prompt", None, None
# Keyword rules filtered by mode
if mode != MODE_AR_ONLY:
for label, pattern, pool, weights, min_mode in _keyword_rules:
if mode == MODE_LIMITED and min_mode == MODE_ALL:
continue
if pattern.search(prompt):
return None, label, pool, weights
return None, None, None, None
def _dims(ratio_str, megapixels, round_to):
w_ratio, h_ratio = map(int, ratio_str.split(":"))
total_pixels = float(megapixels) * 1_000_000
dimension = (total_pixels / (w_ratio * h_ratio)) ** 0.5
width = round(int(dimension * w_ratio) / round_to) * round_to
height = round(int(dimension * h_ratio) / round_to) * round_to
return width, height
# ── API routes ────────────────────────────────────────────────────────────────
try:
from server import PromptServer
from aiohttp import web
@PromptServer.instance.routes.get("/smart_resolution/keywords")
async def _get_keywords(request):
try:
with open(_KEYWORDS_FILE, encoding="utf-8") as f:
data = json.load(f)
return web.json_response(data)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/smart_resolution/keywords")
async def _save_keywords(request):
try:
data = await request.json()
with open(_KEYWORDS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
_load_keywords()
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
except Exception:
pass # PromptServer not available outside ComfyUI (e.g. tests)
# ── Node ──────────────────────────────────────────────────────────────────────
class ResolutionNode:
@classmethod
def INPUT_TYPES(cls):
megapixel_options = [f"{i / 10:.1f}" for i in range(1, 26)]
return {
"required": {
"megapixels": (megapixel_options, {"default": "1.0"}),
"aspect_ratio": (ALL_RATIOS_LABELED, {"default": "1:1 (Perfect Square)"}),
"divisible_by": (["8", "16", "32", "64"], {"default": "64"}),
"keyword_mode": (MODES, {"default": MODE_LIMITED}),
"randomize": ("BOOLEAN", {"default": False, "label_on": "Random", "label_off": "Fixed"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
},
"optional": {
"prompt": ("STRING", {"forceInput": True}),
},
}
RETURN_TYPES = ("INT", "INT", "STRING")
RETURN_NAMES = ("width", "height", "ratio")
FUNCTION = "calculate"
CATEGORY = "Resolution"
@classmethod
def IS_CHANGED(cls, randomize, **kwargs):
if randomize:
return float("nan")
return False
def calculate(self, megapixels, aspect_ratio, divisible_by, keyword_mode, randomize, seed, prompt=""):
round_to = int(divisible_by)
rng = random.Random(seed)
fixed_ratio, label, pool, weights = _detect(prompt or "", keyword_mode)
if fixed_ratio:
ratio_str = fixed_ratio
elif pool is not None:
ratio_str = rng.choices(pool, weights=weights, k=1)[0]
else:
ratio_str = rng.choice(ALL_RATIOS) if randomize else unlabel(aspect_ratio)
label = None
ratio_out = f"{ratio_str} ({label})" if label else ratio_str
width, height = _dims(ratio_str, megapixels, round_to)
return width, height, ratio_out
NODE_CLASS_MAPPINGS = {
"ResolutionNode": ResolutionNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ResolutionNode": "Smart Resolution",
}