feat: Resolution Preset node — pick a size instead of computing one

With exact_ratio on, the valid outputs are a discrete lattice, so the
continuous megapixels input advertises precision that does not exist and
pushes the lookup table out of the node and into a sticky note beside the
graph. This adds a third node that is a single combo of 79 known-good
resolutions, each labelled

    16:9 · 2048 × 1152 · 2.36 MP · K2+H3+SDXL

with the trailing tag derived from divisibility (grid 16 / 32 / 64), not
a hand-kept table.

The list is generated at import time by build_presets() from the same
lattice_step() the solver uses, so it cannot disagree with solve_exact().
Per ratio it picks the coarsest grid still yielding six sizes: 1:1 has
L=16 and would otherwise contribute 105 near-identical entries, whereas
the coarse grid lands it on canonical squares (512 ... 2048). Coarse
ratios like 16:9 fall through to grid 16 and keep their full set.

Additive only — ResolutionSelectorMP and ResolutionAlignToGrid are
untouched and remain the escape hatch for custom ratios and for sizes
off the preset list.

Verified: ruff clean; comfy node validate passes; all three nodes
register; all 79 presets checked for exact ratio, 16-grid alignment,
correct compat tag, correct megapixels, label/value agreement and
execute() round-trip — zero problems, labels and sizes both unique;
existing solver anchors and all 69 README table rows unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdgKVDqEbD14EJ4RC3GPmg
This commit is contained in:
dreevelle
2026-08-09 14:55:13 +01:00
co-authored by Claude Opus 5
parent 2d740a2b80
commit 1c3f13b1fe
4 changed files with 195 additions and 13 deletions
+13 -3
View File
@@ -5,9 +5,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is
A single-file ComfyUI custom node package. All code lives in `__init__.py`; there
is no build step, test suite, or linter config. Two nodes,
`ResolutionSelectorMP` and `ResolutionAlignToGrid`, both pure integer maths with
no torch/numpy involvement.
is no build step, test suite, or linter config. Three nodes —
`ResolutionPreset`, `ResolutionSelectorMP` and `ResolutionAlignToGrid` — all pure
integer maths with no torch/numpy involvement.
`ResolutionPreset`'s combo options are **generated at import time** by
`build_presets()` from the same `lattice_step()` the solver uses, so the list can
never disagree with `solve_exact()`. Do not hand-edit resolutions into it. The
per-ratio grid choice (coarsest alignment yielding `PRESET_MIN_ENTRIES` sizes)
exists because fine-lattice ratios like 1:1 have L=16 and would otherwise emit
~105 entries; changing `PRESET_MIN_MP` / `PRESET_MAX_MP` / `PRESET_MIN_ENTRIES`
silently reshapes every label, and **labels are the public API** — a saved
workflow stores the selected label string, so altering the format or the tuning
constants breaks existing graphs.
Registered through ComfyUI's **V3 schema API**, not the legacy
`NODE_CLASS_MAPPINGS` dict: `comfy_entrypoint()` returns a `ComfyExtension` whose
+41 -1
View File
@@ -2,13 +2,22 @@
Resolution maths for latent-space models, done exactly.
Two nodes, no dependencies:
Three nodes, no dependencies:
| Node | Does |
|---|---|
| **Resolution Preset** | pick a known-good resolution from a list — start here |
| **Resolution Selector (Real MP)** | aspect ratio + real megapixels → grid-aligned width/height, ratio held exact |
| **Align Resolution to Grid** | snap an arbitrary size onto the grid (1920×1080 → 1920×1088) |
```
Resolution Preset
┌──────────────────────────────────────────────────┐
│ preset 16:9 · 2048 × 1152 · 2.36 MP · K2+H3+SDXL│
└──────────────────────────────────────────────────┘
↳ width 2048 height 1152 megapixels 2.36 label "2048x1152"
```
## Why
### 1. "Megapixels" usually isn't megapixels
@@ -310,6 +319,37 @@ the useful rows are all still there (1024×576, 1536×864, 2048×1152, 2560×144
## Nodes
### Resolution Preset
One combo of 79 known-good resolutions. Every entry is exactly on its aspect ratio and
on the patch grid, so there is nothing to compute and nothing to look up.
**Inputs** — `preset`, formatted `ratio · width × height · real MP · compatible models`.
The trailing tag comes from the resolution's divisibility, not a hand-kept table:
| Tag | Grid | Models |
|---|---|---|
| `K2` | 16 | Krea 2, Flux, SD3, Qwen-Image, Wan |
| `K2+H3` | 32 | the above, plus MiniMax H3 |
| `K2+H3+SDXL` | 64 | the above, plus SDXL / SD1.5 — universally safe |
**Outputs** — `width`, `height`, `megapixels`, `label`.
The list is **generated from the lattice at import time**, never hand-maintained, so it
cannot drift from what `Resolution Selector (Real MP)` produces. Per ratio it uses the
coarsest grid that still offers at least six sizes: fine-lattice ratios like 1:1 (where
L=16, so every 16 px is legal) would otherwise contribute a hundred near-identical
entries, and the coarse grid lands them on canonical sizes — 512, 768, 1024, 1536, 2048
— instead. Coarse-lattice ratios like 16:9 fall through to grid 16 and keep their full
useful set.
Why a list and not a number: with `exact_ratio` on, the valid outputs *are* a discrete
lattice. A continuous megapixel input advertises precision that does not exist, and
pushes the lookup table out of the node and into your head (or a sticky note next to
the graph). Reach for `Resolution Selector (Real MP)` when you need a ratio or a size
this list does not carry — 21:9 at exactly 2352×1008, say, which falls on grid 16 while
the 21:9 presets use grid 64.
### Resolution Selector (Real MP)
**Inputs** — `aspect_ratio` (11 presets + Custom), `megapixels` (real, 10⁶),
+140 -8
View File
@@ -2,14 +2,21 @@
Resolution maths for latent-space models, done exactly.
Two nodes:
Three nodes:
``ResolutionPreset``
The everyday one. A single list of known-good resolutions, each exactly on
its aspect ratio and on the model's patch grid, tagged with the model
families it is legal for. Generated from the lattice at import time, so it
cannot drift from what the solver below produces.
``ResolutionSelectorMP``
Aspect ratio + a *real* megapixel target (10^6 pixels, not 1024^2) ->
width/height that land on the model's patch grid **without drifting the
aspect ratio**. Rounding each axis independently -- what the built-in
Resolution Selector does -- turns 9:16 into 0.5649 instead of 0.5625. This
solves on the lattice of grid-aligned exact-ratio sizes instead.
The parametric one, for custom ratios and arbitrary budgets. Aspect ratio +
a *real* megapixel target (10^6 pixels, not 1024^2) -> width/height that
land on the model's patch grid **without drifting the aspect ratio**.
Rounding each axis independently -- what the built-in Resolution Selector
does -- turns 9:16 into 0.5649 instead of 0.5625. This solves on the
lattice of grid-aligned exact-ratio sizes instead.
``ResolutionAlignToGrid``
Snap an arbitrary width/height (e.g. 1920x1080) to the same grid
@@ -48,7 +55,7 @@ from typing_extensions import override
from comfy_api.latest import ComfyExtension, IO
__version__ = "1.0.0"
__version__ = "1.1.0"
# ---------------------------------------------------------------------------
@@ -142,6 +149,90 @@ def align_value(value: int, alignment: int, mode: str) -> int:
return max(alignment, snapped)
# ---------------------------------------------------------------------------
# Preset generation
#
# With exact_ratio on, the valid outputs are a discrete lattice, so a
# continuous megapixel input advertises precision that does not exist. These
# presets are the lattice, rendered readable -- derived at import time, never
# hand-maintained, so they cannot drift from what the solver produces.
# ---------------------------------------------------------------------------
PRESET_MIN_MP = 0.25
PRESET_MAX_MP = 4.3
PRESET_MIN_ENTRIES = 6 # coarsest grid that still offers this many sizes wins
PRESET_MAX_ENTRIES = 9 # thin geometrically beyond this, keeping both ends
# 16:9 and 9:16 lead: the common case for video and for delivery to a screen.
PRESET_RATIO_ORDER = [
AspectRatio.WIDESCREEN_H, AspectRatio.WIDESCREEN_V,
AspectRatio.SQUARE,
AspectRatio.PHOTO_H, AspectRatio.PHOTO_V,
AspectRatio.STANDARD_H, AspectRatio.STANDARD_V,
AspectRatio.SOCIAL_H, AspectRatio.SOCIAL_V,
AspectRatio.ULTRAWIDE_H, AspectRatio.ULTRAWIDE_V,
]
def compat_tag(width: int, height: int) -> str:
"""Which model families a resolution is legal for, from its divisibility."""
if width % 64 == 0 and height % 64 == 0:
return "K2+H3+SDXL"
if width % 32 == 0 and height % 32 == 0:
return "K2+H3"
return "K2"
def _lattice_points(w_ratio, h_ratio, alignment):
step = lattice_step(w_ratio, h_ratio, alignment)
out, n = [], 1
while True:
w, h = w_ratio * step * n, h_ratio * step * n
mp = w * h / 1_000_000
if mp > PRESET_MAX_MP:
return out
if mp >= PRESET_MIN_MP:
out.append((w, h, mp))
n += 1
def build_presets() -> dict[str, tuple[int, int]]:
"""Readable label -> (width, height), covering every aspect ratio.
Per ratio, take the *coarsest* grid that still yields PRESET_MIN_ENTRIES
sizes. Fine-lattice ratios (1:1 has L=16, so 16n x 16n) would otherwise
flood the list with a hundred near-identical entries; the coarse grid
lands them on canonical sizes instead (512, 768, 1024, ...). Coarse-lattice
ratios like 16:9 fall through to grid 16 and keep their full useful set.
"""
presets = {}
for ratio in PRESET_RATIO_ORDER:
w_ratio, h_ratio = ASPECT_RATIOS[ratio]
divisor = math.gcd(w_ratio, h_ratio)
a, b = w_ratio // divisor, h_ratio // divisor
points = []
for alignment in (256, 128, 64, 32, 16):
points = _lattice_points(a, b, alignment)
if len(points) >= PRESET_MIN_ENTRIES:
break
if len(points) > PRESET_MAX_ENTRIES:
span = len(points) - 1
keep = {round(i * span / (PRESET_MAX_ENTRIES - 1)) for i in range(PRESET_MAX_ENTRIES)}
points = [points[i] for i in sorted(keep)]
# label with the declared ratio (21:9), not the reduced pair (7:3)
name = ratio.value.split(" ")[0]
for w, h, mp in points:
presets[f"{name} · {w} × {h} · {mp:.2f} MP · {compat_tag(w, h)}"] = (w, h)
return presets
PRESETS = build_presets()
PRESET_LABELS = list(PRESETS)
DEFAULT_PRESET = next(k for k, v in PRESETS.items() if v == (2048, 1152))
# ---------------------------------------------------------------------------
# Nodes
# ---------------------------------------------------------------------------
@@ -238,6 +329,47 @@ class ResolutionSelectorMP(IO.ComfyNode):
return IO.NodeOutput(width, height, (width * height) / 1_000_000, f"{width}x{height}")
class ResolutionPreset(IO.ComfyNode):
"""Pick a known-good resolution from a list instead of computing one."""
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="ResolutionPreset",
display_name="Resolution Preset",
category="utilities",
description=(
"Known-good resolutions, picked from a list. Every entry is exactly "
"on its aspect ratio and on the model's patch grid. The tag says which "
"models it is legal for: K2 = Krea 2 / Flux / SD3 / Qwen-Image / Wan "
"(grid 16), K2+H3 adds MiniMax H3 (grid 32), K2+H3+SDXL adds the UNet "
"models (grid 64)."
),
inputs=[
IO.Combo.Input(
"preset",
options=PRESET_LABELS,
default=DEFAULT_PRESET,
tooltip=(
"ratio · width × height · real megapixels · compatible models. "
"For a ratio or size not listed, use Resolution Selector (Real MP)."
),
),
],
outputs=[
IO.Int.Output(display_name="width"),
IO.Int.Output(display_name="height"),
IO.Float.Output(display_name="megapixels", tooltip="Real megapixels (10^6 pixels)."),
IO.String.Output(display_name="label", tooltip='Dimensions as "2048x1152".'),
],
)
@classmethod
def execute(cls, preset: str) -> IO.NodeOutput:
width, height = PRESETS[preset]
return IO.NodeOutput(width, height, (width * height) / 1_000_000, f"{width}x{height}")
class ResolutionAlignToGrid(IO.ComfyNode):
"""Snap an arbitrary width/height onto the model's patch grid."""
@@ -282,7 +414,7 @@ class ResolutionAlignToGrid(IO.ComfyNode):
class ResolutionToolsExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [ResolutionSelectorMP, ResolutionAlignToGrid]
return [ResolutionPreset, ResolutionSelectorMP, ResolutionAlignToGrid]
async def comfy_entrypoint() -> ResolutionToolsExtension:
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-resolution-tools"
description = "Resolution maths for latent-space models, done exactly. Real megapixels (10^6, not 1024^2), patch-grid alignment for Krea 2 / MiniMax H3 / Flux / SDXL, and aspect ratios held exact instead of drifting on rounding."
version = "1.0.0"
version = "1.1.0"
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"