Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.DS_Store
|
||||
.claude/
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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 = `<tr style="color:#999; text-align:left; border-bottom:1px solid #444;">
|
||||
<th style="padding:5px 4px; width:90px;">Label</th>
|
||||
<th style="padding:5px 4px;">Words (comma-separated)</th>
|
||||
<th style="padding:5px 4px; width:200px;">Ratios</th>
|
||||
<th style="padding:5px 4px; width:130px;">Weights</th>
|
||||
<th style="padding:5px 4px; width:75px;">Category</th>
|
||||
<th style="padding:5px 4px; width:28px;"></th>
|
||||
</tr>`;
|
||||
|
||||
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);
|
||||
};
|
||||
},
|
||||
});
|
||||
+199
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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]
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user