commit 4f29341e193c42b9ec7d979199b53ed5da14aade Author: dawncreatescode Date: Fri Jun 5 21:04:48 2026 +0200 Initial release v1.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9e1a17 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +*.pyo +.DS_Store +.claude/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a28064 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 YOUR_GITHUB_USERNAME + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..edddf63 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# ComfyUI Smart Resolution + +A ComfyUI custom node that converts a megapixel target and aspect ratio into pixel dimensions (`width` × `height`). The ratio can be fixed, randomized, or **detected automatically from a connected prompt**. + +--- + +## Features + +- **23 aspect ratios** from 1:1 to 32:9 (portrait, square, landscape) +- **Prompt-based detection** — connect your text prompt and the node picks the right ratio automatically +- **Explicit ratio override** — writing `16:9` anywhere in the prompt forces that exact ratio +- **Keyword matching** — terms like `cinematic`, `widescreen`, `portrait`, or `anamorphic` map to weighted ratio pools +- **Randomization** — seed-controlled random ratio when nothing is detected +- **Editable keyword rules** — click *Edit Keywords* on the node to add, remove, or modify rules live + +--- + +## Installation + +**Via ComfyUI Manager:** search for `Smart Resolution`. + +**Manual:** +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/YOUR_GITHUB_USERNAME/comfyui-ResolutionAndAspectRatio +``` + +Restart ComfyUI. The node appears under the **Resolution** category. + +--- + +## Inputs + +| Input | Type | Description | +|-------|------|-------------| +| `megapixels` | Dropdown | Target image size, 0.1–2.5 MP | +| `aspect_ratio` | Dropdown | Fallback ratio when detection is off or finds nothing | +| `divisible_by` | Dropdown | Round dimensions to this multiple (8 / 16 / 32 / 64) | +| `keyword_mode` | Dropdown | Controls which prompt keywords trigger detection | +| `randomize` | Boolean | Pick a random ratio when no keyword/prompt match fires | +| `seed` | INT | Controls which ratio is picked when randomizing | +| `prompt` | STRING *(optional)* | Text to scan — connect to the same node as your CLIP encoder | + +## Outputs + +| Output | Description | +|--------|-------------| +| `width` | Image width in pixels | +| `height` | Image height in pixels | +| `ratio` | Selected ratio with detection source, e.g. `16:9 (cinematic)` | + +> `divisible_by 64` works for most models (SD 1.5, SDXL). Use 8 or 16 for custom VAEs. + +--- + +## Ratio Detection + +Detection runs in this priority order: + +1. **Explicit ratio in the prompt** — `16:9` anywhere forces that exact ratio +2. **Keyword match** — picks from a weighted pool based on matched term +3. **Fallback** — fixed `aspect_ratio` dropdown, or random if *Randomize* is on + +The `ratio` output string shows what triggered the selection: `21:9 (from prompt)`, `16:9 (cinematic)`, etc. Connect it to a Show Text node to monitor it. + +### Keyword Mode + +| Mode | What triggers | +|------|--------------| +| **Off** | Nothing — uses fixed or random ratio | +| **AR only** | Only explicit ratios written in the prompt | +| **Limited keywords** *(default)* | Explicit ratios + unambiguous AR terms | +| **All keywords** | Explicit ratios + all rules including content words | + +`Limited keywords` only matches terms that clearly describe a format (`widescreen`, `anamorphic`, `ultrawide`). `All keywords` also catches content words like `cinematic`, `portrait`, `poster`, or `phone`. + +### Default Keyword Rules + +| Words | Ratio pool | Mode | +|-------|------------|------| +| `anamorphic` | `21:9` | Limited+ | +| `imax` | `4:3` | Limited+ | +| `ultrawide` | `21:9` `32:9` | Limited+ | +| `widescreen` | `16:9` `19:9` `21:9` | Limited+ | +| `panorama`, `panoramic` | `16:9` `19:9` `21:9` `32:9` | Limited+ | +| `square`, `avatar`, `thumbnail` | `1:1` | All | +| `mobile`, `phone`, `smartphone` | `9:16` `9:19` `9:21` | All | +| `poster` | `2:3` `3:4` `4:5` | All | +| `cinematic` | `16:9` `21:9` `32:9` | All | +| `banner` | `16:9` `21:9` `32:9` | All | +| `wallpaper` | `16:9` `21:9` `32:9` | All | +| `landscape`, `horizontal`, `wide angle`, `wide shot` | All landscape ratios | All | +| `portrait`, `vertical`, `tall` | All portrait ratios | All | + +When a keyword maps to multiple ratios, one is picked by weighted random using the seed. Change the seed for a different pick within the same pool. + +--- + +## Editing Keywords + +Click **Edit Keywords** on the node to open the live editor. Each rule has: + +- **Words** — comma-separated trigger words/phrases (case-insensitive, phrase-aware) +- **Ratios** — comma-separated ratio pool, or `$LANDSCAPE` / `$PORTRAIT` for the full sets +- **Weights** — optional; controls probability of each ratio (leave blank for equal chance) +- **Category** — `limited` = active in Limited + All modes; `all` = active in All mode only + +Changes are saved to `keywords.json` in the node folder and take effect immediately. + +--- + +## Supported Aspect Ratios + +**Portrait:** `2:3` `3:4` `3:5` `4:5` `5:7` `5:8` `7:9` `9:16` `9:19` `9:21` `9:32` + +**Square:** `1:1` + +**Landscape:** `3:2` `4:3` `5:3` `5:4` `7:5` `8:5` `9:7` `16:9` `19:9` `21:9` `32:9` + +--- + +## License + +[MIT](LICENSE) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..3cb3152 --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +from .resolution_prompt_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +WEB_DIRECTORY = "js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/js/keywords_editor.js b/js/keywords_editor.js new file mode 100644 index 0000000..67e2a4c --- /dev/null +++ b/js/keywords_editor.js @@ -0,0 +1,211 @@ +import { app } from "../../scripts/app.js"; + +console.log("[SmartResolution] keywords_editor.js loaded"); + +const API = "/smart_resolution"; + +const STYLE = { + overlay: ` + position: fixed; inset: 0; background: rgba(0,0,0,0.75); + display: flex; align-items: center; justify-content: center; z-index: 9999;`, + dialog: ` + background: #1a1a2e; color: #e0e0e0; border: 1px solid #444; + border-radius: 8px; padding: 20px; width: 860px; max-width: 95vw; + max-height: 82vh; display: flex; flex-direction: column; gap: 12px; + font-family: monospace; font-size: 13px;`, + input: ` + background: #252535; color: #e0e0e0; border: 1px solid #555; + border-radius: 3px; padding: 3px 6px; width: 100%; box-sizing: border-box; + font-family: monospace; font-size: 12px;`, + btnPrimary: ` + background: #1a3a6a; color: #fff; border: 1px solid #3a6aaa; + border-radius: 4px; padding: 6px 18px; cursor: pointer;`, + btnSecondary: ` + background: #2e2e2e; color: #ccc; border: 1px solid #555; + border-radius: 4px; padding: 6px 18px; cursor: pointer;`, + btnAdd: ` + background: #1e3a1e; color: #8f8; border: 1px solid #3a6a3a; + border-radius: 4px; padding: 5px 14px; cursor: pointer; align-self: flex-start;`, + btnDel: ` + background: #5a1a1a; color: #faa; border: 1px solid #8a3a3a; + border-radius: 3px; padding: 2px 7px; cursor: pointer;`, +}; + +function el(tag, style, attrs = {}) { + const e = document.createElement(tag); + if (style) e.style.cssText = style; + Object.assign(e, attrs); + return e; +} + +function makeInput(value, placeholder = "") { + const inp = el("input", STYLE.input, { value, placeholder }); + return inp; +} + +function makeSelect(selected) { + const sel = el("select", STYLE.input); + for (const opt of ["limited", "all"]) { + const o = document.createElement("option"); + o.value = o.textContent = opt; + o.selected = opt === selected; + sel.appendChild(o); + } + return sel; +} + +function makeRow(tbody, entry = null) { + const tr = document.createElement("tr"); + tr.style.borderBottom = "1px solid #2a2a3a"; + + const ratioVal = Array.isArray(entry?.ratios) + ? entry.ratios.join(", ") + : (entry?.ratios ?? ""); + + const inputs = { + label: makeInput(entry?.label ?? "", "label"), + words: makeInput((entry?.words ?? []).join(", "), "word1, word2, …"), + ratios: makeInput(ratioVal, "16:9, 21:9 or $LANDSCAPE"), + weights: makeInput((entry?.weights ?? []).join(", "), "3, 2, 1 (optional)"), + category: makeSelect(entry?.category ?? "all"), + }; + + for (const [key, widget] of Object.entries(inputs)) { + const td = el("td", "padding: 4px;"); + widget.dataset.col = key; + td.appendChild(widget); + tr.appendChild(td); + } + + const delTd = el("td", "padding: 4px; text-align: center;"); + const delBtn = el("button", STYLE.btnDel, { textContent: "✕", title: "Remove row" }); + delBtn.addEventListener("click", () => tr.remove()); + delTd.appendChild(delBtn); + tr.appendChild(delTd); + + tbody.appendChild(tr); + return inputs; +} + +function collectRows(tbody) { + return [...tbody.querySelectorAll("tr")].map(tr => { + const get = col => tr.querySelector(`[data-col="${col}"]`)?.value?.trim() ?? ""; + + const rawRatios = get("ratios"); + const ratios = rawRatios.startsWith("$") + ? rawRatios + : rawRatios.split(",").map(s => s.trim()).filter(Boolean); + + const rawWeights = get("weights"); + const weightArr = rawWeights + ? rawWeights.split(",").map(s => parseFloat(s.trim())).filter(n => !isNaN(n)) + : []; + + return { + label: get("label"), + words: get("words").split(",").map(s => s.trim()).filter(Boolean), + ratios, + weights: weightArr.length ? weightArr : null, + category: get("category"), + }; + }); +} + +async function openEditor() { + let keywords; + try { + const resp = await fetch(`${API}/keywords`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + keywords = await resp.json(); + } catch (err) { + alert(`Could not load keywords: ${err.message}`); + return; + } + + // ── Overlay + dialog ────────────────────────────────────────────────────── + const overlay = el("div", STYLE.overlay); + const dialog = el("div", STYLE.dialog); + overlay.appendChild(dialog); + + // Title + const title = el("h3", "margin: 0; color: #a0c4ff; font-size: 15px;", + { textContent: "Edit Keywords" }); + + const hint = el("p", "margin: 0; font-size: 11px; color: #777;", { + textContent: + "Limited keywords activate in \u201cLimited\u201d and \u201cAll\u201d modes; All keywords only in \u201cAll\u201d mode. " + + "Use $LANDSCAPE or $PORTRAIT as ratio values to reference the full ratio sets.", + }); + + // Table + const tableWrap = el("div", "overflow-y: auto; flex: 1; min-height: 0;"); + const table = el("table", "width: 100%; border-collapse: collapse;"); + + const thead = document.createElement("thead"); + thead.innerHTML = ` + Label + Words (comma-separated) + Ratios + Weights + Category + + `; + + const tbody = document.createElement("tbody"); + table.append(thead, tbody); + tableWrap.appendChild(table); + + for (const entry of keywords) makeRow(tbody, entry); + + // Add row button + const addBtn = el("button", STYLE.btnAdd, { textContent: "+ Add row" }); + addBtn.addEventListener("click", () => makeRow(tbody)); + + // Footer + const footer = el("div", "display:flex; justify-content:flex-end; gap:8px; align-items:center;"); + const status = el("span", "flex:1; font-size:11px; color:#aaa;"); + + const cancelBtn = el("button", STYLE.btnSecondary, { textContent: "Cancel" }); + cancelBtn.addEventListener("click", () => overlay.remove()); + + const saveBtn = el("button", STYLE.btnPrimary, { textContent: "Save" }); + saveBtn.addEventListener("click", async () => { + saveBtn.disabled = true; + status.textContent = "Saving…"; + try { + const resp = await fetch(`${API}/keywords`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(collectRows(tbody)), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + status.textContent = "Saved."; + setTimeout(() => overlay.remove(), 700); + } catch (err) { + status.textContent = `Error: ${err.message}`; + saveBtn.disabled = false; + } + }); + + footer.append(status, cancelBtn, saveBtn); + + // Close on backdrop click + overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); }); + + dialog.append(title, hint, tableWrap, addBtn, footer); + document.body.appendChild(overlay); +} + +// ── Register extension ──────────────────────────────────────────────────────── +app.registerExtension({ + name: "SmartResolution.KeywordsEditor", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "ResolutionNode") return; + + const orig = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + orig?.apply(this, arguments); + this.addWidget("button", "Edit Keywords", "edit_keywords", openEditor); + }; + }, +}); diff --git a/keywords.json b/keywords.json new file mode 100644 index 0000000..494b72d --- /dev/null +++ b/keywords.json @@ -0,0 +1,199 @@ +[ + { + "label": "anamorphic", + "words": [ + "anamorphic" + ], + "ratios": [ + "21:9" + ], + "weights": null, + "category": "limited" + }, + { + "label": "imax", + "words": [ + "imax" + ], + "ratios": [ + "4:3" + ], + "weights": null, + "category": "limited" + }, + { + "label": "ultrawide", + "words": [ + "ultrawide" + ], + "ratios": [ + "21:9", + "32:9" + ], + "weights": [ + 3, + 1 + ], + "category": "limited" + }, + { + "label": "widescreen", + "words": [ + "widescreen" + ], + "ratios": [ + "16:9", + "19:9", + "21:9" + ], + "weights": [ + 3, + 2, + 1 + ], + "category": "limited" + }, + { + "label": "panoramic", + "words": [ + "panorama", + "panoramic" + ], + "ratios": [ + "16:9", + "19:9", + "21:9", + "32:9" + ], + "weights": [ + 1, + 2, + 3, + 2 + ], + "category": "limited" + }, + { + "label": "square", + "words": [ + "square", + "avatar", + "thumbnail" + ], + "ratios": [ + "1:1" + ], + "weights": null, + "category": "all" + }, + { + "label": "mobile", + "words": [ + "mobile", + "phone", + "smartphone" + ], + "ratios": [ + "9:16", + "9:19", + "9:21" + ], + "weights": [ + 5, + 2, + 1 + ], + "category": "all" + }, + { + "label": "poster", + "words": [ + "poster" + ], + "ratios": [ + "2:3", + "3:4", + "4:5" + ], + "weights": [ + 3, + 2, + 1 + ], + "category": "all" + }, + { + "label": "cinematic", + "words": [ + "cinematic" + ], + "ratios": [ + "16:9", + "21:9", + "32:9" + ], + "weights": [ + 3, + 2, + 1 + ], + "category": "all" + }, + { + "label": "banner", + "words": [ + "banner" + ], + "ratios": [ + "16:9", + "21:9", + "32:9" + ], + "weights": [ + 2, + 2, + 3 + ], + "category": "all" + }, + { + "label": "wallpaper", + "words": [ + "wallpaper" + ], + "ratios": [ + "16:9", + "21:9", + "32:9" + ], + "weights": [ + 3, + 2, + 1 + ], + "category": "all" + }, + { + "label": "landscape", + "words": [ + "landscape", + "horizontal", + "wide angle", + "wide shot" + ], + "ratios": "$LANDSCAPE", + "weights": null, + "category": "all" + }, + { + "label": "portrait", + "words": [ + "portrait", + "vertical", + "tall" + ], + "ratios": "$PORTRAIT", + "weights": null, + "category": "all" + } +] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..41227ab --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "comfyui-ResolutionAndAspectRatio" +version = "1.0.0" +description = "ComfyUI node that converts megapixels + aspect ratio into width/height, with prompt-based ratio detection and randomization." +license = { file = "LICENSE" } +readme = "README.md" +requires-python = ">=3.9" +dependencies = [] + +[project.urls] +Repository = "https://github.com/YOUR_GITHUB_USERNAME/comfyui-ResolutionAndAspectRatio" +Issues = "https://github.com/YOUR_GITHUB_USERNAME/comfyui-ResolutionAndAspectRatio/issues" + +[tool.comfy] +PublisherId = "YOUR_GITHUB_USERNAME" +DisplayName = "Smart Resolution" +Icon = "" +Tags = ["resolution", "aspect ratio", "utility", "dimensions"] diff --git a/ratios.py b/ratios.py new file mode 100644 index 0000000..539a883 --- /dev/null +++ b/ratios.py @@ -0,0 +1,57 @@ +# Ordered list of (ratio, short descriptor) used across all nodes. +_RATIO_ENTRIES = [ + ("1:1", "Perfect Square"), + # Portrait + ("2:3", "Classic Portrait"), + ("3:4", "Standard Portrait"), + ("3:5", "Tall Portrait"), + ("4:5", "Artistic Portrait"), + ("5:7", "Balanced Portrait"), + ("5:8", "Elegant Portrait"), + ("7:9", "Modern Portrait"), + ("9:16", "Vertical Video"), + ("9:19", "Tall Mobile"), + ("9:21", "Ultra Tall"), + ("9:32", "Extreme Tall"), + # Landscape + ("3:2", "Classic Landscape"), + ("4:3", "Standard Landscape"), + ("5:3", "Wide Landscape"), + ("5:4", "Balanced Landscape"), + ("7:5", "Elegant Landscape"), + ("8:5", "Widescreen"), + ("9:7", "Artful Landscape"), + ("16:9", "HD Cinematic"), + ("19:9", "Modern Ultrawide"), + ("21:9", "Anamorphic"), + ("32:9", "Super Ultrawide"), +] + +_LABEL_MAP = {r: label for r, label in _RATIO_ENTRIES} + +LANDSCAPE_RATIOS = ["3:2", "4:3", "5:3", "5:4", "7:5", "8:5", + "9:7", "16:9", "19:9", "21:9", "32:9"] + +PORTRAIT_RATIOS = ["2:3", "3:4", "3:5", "4:5", "5:7", "5:8", + "7:9", "9:16", "9:19", "9:21", "9:32"] + +ALL_RATIOS = ["1:1"] + PORTRAIT_RATIOS + LANDSCAPE_RATIOS + +RATIO_SET = set(ALL_RATIOS) + + +def labeled(ratio: str) -> str: + """'16:9' → '16:9 (HD Cinematic)'""" + desc = _LABEL_MAP.get(ratio, "") + return f"{ratio} ({desc})" if desc else ratio + + +def unlabel(labeled_str: str) -> str: + """'16:9 (HD Cinematic)' → '16:9'""" + return labeled_str.split(" ")[0] + + +# Pre-built labeled lists for INPUT_TYPES dropdowns +ALL_RATIOS_LABELED = [labeled(r) for r in ALL_RATIOS] +LANDSCAPE_RATIOS_LABELED = [labeled(r) for r in LANDSCAPE_RATIOS] +PORTRAIT_RATIOS_LABELED = [labeled(r) for r in PORTRAIT_RATIOS] diff --git a/resolution_prompt_node.py b/resolution_prompt_node.py new file mode 100644 index 0000000..e06559f --- /dev/null +++ b/resolution_prompt_node.py @@ -0,0 +1,183 @@ +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", +}