From 1c3f13b1fe6f7d7f9da2674c95d3f0b808018a79 Mon Sep 17 00:00:00 2001 From: dreevelle <123705989+dreevelle@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:55:13 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Resolution=20Preset=20node=20=E2=80=94?= =?UTF-8?q?=20pick=20a=20size=20instead=20of=20computing=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CdgKVDqEbD14EJ4RC3GPmg --- CLAUDE.md | 16 +++++- README.md | 42 +++++++++++++- __init__.py | 148 ++++++++++++++++++++++++++++++++++++++++++++++--- pyproject.toml | 2 +- 4 files changed, 195 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 94aadd0..d189e5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 5e6373c..bd98c8c 100644 --- a/README.md +++ b/README.md @@ -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⁶), diff --git a/__init__.py b/__init__.py index 374be67..c87b163 100644 --- a/__init__.py +++ b/__init__.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index c5fb68d..aff6092 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"