Initial commit: exact resolution maths for latent-space models
Two nodes addressing distinct problems with ComfyUI's built-in
Resolution Selector (comfy_extras/nodes_resolution.py).
ResolutionSelectorMP
Core computes `megapixels * 1024 * 1024`, so every target is inflated
4.86% in area: 2.36 MP at 9:16 yields 1184x2096 = 2.48 real MP. This
uses 10^6.
Core also rounds each axis onto the grid independently, which drifts
the aspect ratio (that same result is 0.5649, not 0.5625). With
exact_ratio on, this solves instead on the lattice of sizes that are
simultaneously grid-aligned and exactly on-ratio: for a reduced ratio
a:b and alignment m those are (a*L*n, b*L*n) where
L = lcm(m / gcd(a, m), m / gcd(b, m))
9:16 at m=16 gives the lattice 144n x 256n, and n=8 lands on
1152x2048 -- exact 9:16, on the grid, 2.36 MP. solve_nearest()
preserves core's behaviour behind exact_ratio=False for when the
lattice is too coarse.
ResolutionAlignToGrid
Snaps arbitrary dimensions onto the grid (1920x1080 -> 1920x1088),
with round/up/down.
Alignment is vae_spatial_downscale * dit_patch_size, verified against
the ComfyUI source rather than assumed: 16 for Krea 2 (Wan21 inherits
spacial_downscale_ratio 8, patch 2), 32 for MiniMax H3
(spacial_downscale_ratio 16, patch_size (1,2,2)), 64 for SDXL/SD1.5.
Off-grid dimensions get circular-padded by
comfy.ldm.common_dit.pad_to_patch_size -- the pad row is a wrapped copy
of the top of the image, folded into the same patch token as the real
bottom row, which decodes to a band of artifacts along the bottom edge.
Padding is only ever appended, so the artifact is never top/left.
Verified: ruff clean; both schemas construct and both execute() paths
run; a 4000-case fuzz over random ratio/alignment/target triples asserts
w % m == 0, h % m == 0 and w*b == h*a (zero ratio drift); core's formula
reimplemented and reproduces the observed 1184x2096 exactly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdgKVDqEbD14EJ4RC3GPmg
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Files kept in git but not shipped in the registry package.
|
||||
CLAUDE.md
|
||||
.github/
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Publish to Comfy registry
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,108 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 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.
|
||||
|
||||
Registered through ComfyUI's **V3 schema API**, not the legacy
|
||||
`NODE_CLASS_MAPPINGS` dict: `comfy_entrypoint()` returns a `ComfyExtension` whose
|
||||
`get_node_list()` yields `IO.ComfyNode` subclasses. Inputs are declared in
|
||||
`define_schema()` and arrive as named kwargs to `execute()`.
|
||||
|
||||
## Local environment
|
||||
|
||||
- ComfyUI checkout: `/home/dreevelle/comfy/ComfyUI`
|
||||
- Python venv: `/home/dreevelle/comfy-env/bin/python`
|
||||
- `ComfyUI/custom_nodes/ComfyUI-ResolutionTools` is a **symlink to this working
|
||||
tree**, so edits are live on the next server restart:
|
||||
|
||||
```bash
|
||||
ln -sfn /home/dreevelle/Projects/ComfyUI-ResolutionTools \
|
||||
/home/dreevelle/comfy/ComfyUI/custom_nodes/ComfyUI-ResolutionTools
|
||||
```
|
||||
|
||||
This is the pre-publish testing setup. Once the package is on the registry,
|
||||
swap the symlink for an independent clone of the GitHub remote (the
|
||||
FastImageSequence arrangement) so the installed copy tracks releases rather
|
||||
than the working tree.
|
||||
|
||||
## Verifying changes
|
||||
|
||||
`__init__.py` imports `comfy_api.latest` at module scope, so it needs ComfyUI on
|
||||
`sys.path` — but the maths functions are module-level and dependency-free, so
|
||||
they can be exercised directly:
|
||||
|
||||
```bash
|
||||
cd /home/dreevelle/comfy/ComfyUI && PYTHONPATH=/home/dreevelle/comfy/ComfyUI \
|
||||
/home/dreevelle/comfy-env/bin/python -c "
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location('rt', '/home/dreevelle/Projects/ComfyUI-ResolutionTools/__init__.py')
|
||||
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
print(m.solve_exact(9, 16, 16, 2.36e6)) # -> (1152, 2048)
|
||||
"
|
||||
```
|
||||
|
||||
Two invariants any change to `solve_exact` must preserve, and which are worth
|
||||
re-fuzzing over random `(ratio, alignment, target)` triples:
|
||||
|
||||
1. `width % alignment == 0 and height % alignment == 0`
|
||||
2. `width * h_ratio == height * w_ratio` (ratio held exactly, no drift)
|
||||
|
||||
Known-good anchors: `9:16 @ m=16, 2.36 MP -> 1152x2048`;
|
||||
`16:9 @ m=32, 1.33 MP -> 1536x864`; `21:9 @ m=16, 2.37 MP -> 2352x1008`.
|
||||
|
||||
## Why the maths is what it is
|
||||
|
||||
**Real megapixels.** Core's `comfy_extras/nodes_resolution.py:78` uses
|
||||
`megapixels * 1024 * 1024`. That's the SDXL-era convention where "1 MP" meant
|
||||
1024x1024. This package deliberately diverges to `* 1_000_000`. Do not "fix"
|
||||
this to match core — the divergence is the point, and the README documents it.
|
||||
|
||||
**Lattice solving.** `lattice_step()` computes the smallest scale increment that
|
||||
keeps both axes on the alignment grid while holding an exact ratio. Core rounds
|
||||
each axis independently, which drifts the ratio (9:16 at 2.36 MP becomes 0.5649
|
||||
instead of 0.5625). `solve_nearest()` preserves core's behaviour for when the
|
||||
lattice is too coarse; it is opt-in via `exact_ratio=False`.
|
||||
|
||||
**Alignment = VAE spatial downscale x DiT patch size.** Verified against the
|
||||
ComfyUI source rather than assumed:
|
||||
|
||||
- Krea 2 -> 16. `latent_formats.LatentFormat.spacial_downscale_ratio = 8`
|
||||
(inherited by `Wan21`, which `supported_models.Krea2` uses) x `patch=2` in
|
||||
`comfy/ldm/krea2/model.py`.
|
||||
- MiniMax H3 -> 32. `latent_formats.MiniMaxH3Video.spacial_downscale_ratio = 16`
|
||||
x `patch_size=(1, 2, 2)` in `comfy/ldm/minimax/model.py`.
|
||||
- SDXL/SD1.5 -> 64. UNet, no patchify; three downsample stages on an 8x VAE.
|
||||
|
||||
The failure mode when unaligned is `comfy.ldm.common_dit.pad_to_patch_size`,
|
||||
which pads with `padding_mode="circular"` and appends only at the end — hence a
|
||||
bottom/right band, never top/left. If a future model changes VAE or patch size,
|
||||
the alignment table in the README and `ALIGNMENT_TOOLTIP` both need updating.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Version is duplicated in `__version__` (`__init__.py`) and `[project].version`
|
||||
(`pyproject.toml`) — bump both.
|
||||
- `dependencies` stays empty; this package is stdlib-only by design.
|
||||
- Node IDs (`ResolutionSelectorMP`, `ResolutionAlignToGrid`) are the public API.
|
||||
Renaming one breaks every saved workflow that uses it.
|
||||
|
||||
## Releasing
|
||||
|
||||
Intended for the Comfy Registry as `comfyui-resolution-tools` under publisher
|
||||
`dreevelle`, same setup as ComfyUI-FastImageSequence.
|
||||
|
||||
**Changing `[project].version` in `pyproject.toml` on `main` publishes a
|
||||
release.** `.github/workflows/publish_action.yml` watches that path and runs
|
||||
`Comfy-Org/publish-node-action` with the `REGISTRY_ACCESS_TOKEN` repo secret.
|
||||
Don't touch the version field unless a release is intended; republishing an
|
||||
existing version fails with `400 The node version already exists`.
|
||||
|
||||
`.comfyignore` keeps `CLAUDE.md` and `.github/` out of the published archive.
|
||||
`comfy node validate` runs the registry checks locally; `comfy node pack`
|
||||
produces the exact archive that would be uploaded.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 dreevelle
|
||||
|
||||
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,125 @@
|
||||
# ComfyUI-ResolutionTools
|
||||
|
||||
Resolution maths for latent-space models, done exactly.
|
||||
|
||||
Two nodes, no dependencies:
|
||||
|
||||
| Node | Does |
|
||||
|---|---|
|
||||
| **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) |
|
||||
|
||||
## Why
|
||||
|
||||
### 1. "Megapixels" usually isn't megapixels
|
||||
|
||||
ComfyUI's built-in Resolution Selector computes `megapixels * 1024 * 1024`. That's
|
||||
**mebipixels** — every value you type is inflated 4.86% in area. Ask for 2.36 MP at
|
||||
9:16 and you get 1184×2096 = 2.48 real MP.
|
||||
|
||||
This node uses `megapixels * 1_000_000`. Ask for 2.36 and you get 2.36.
|
||||
|
||||
### 2. Rounding each axis independently drifts the aspect ratio
|
||||
|
||||
That same 1184×2096 has a ratio of 0.5649. True 9:16 is 0.5625. Both axes were
|
||||
individually snapped to a multiple of 16, and the ratio fell between the cracks.
|
||||
|
||||
With `exact_ratio` on (the default), this node solves on the **lattice of sizes that
|
||||
are simultaneously grid-aligned and exactly on-ratio**. For a reduced ratio `a:b` and
|
||||
alignment `m`, those are exactly `(a·L·n, b·L·n)` where
|
||||
|
||||
```
|
||||
L = lcm(m / gcd(a, m), m / gcd(b, m))
|
||||
```
|
||||
|
||||
so it picks `n = round(sqrt(target_px / (a·b·L²)))`. For 9:16 at m=16 the lattice is
|
||||
`144n × 256n`, and n=8 gives **1152×2048** — exact 9:16, on the grid, 2.36 MP.
|
||||
|
||||
Turn `exact_ratio` off to get the old independent-rounding behaviour when you'd rather
|
||||
hit the area target precisely and don't care about a hair of ratio drift.
|
||||
|
||||
### 3. Getting alignment wrong bands the bottom of your image
|
||||
|
||||
A latent-space DiT reaches pixels through two reductions:
|
||||
|
||||
```
|
||||
pixels ──/ VAE spatial downscale ──> latent ──/ DiT patch size ──> tokens
|
||||
```
|
||||
|
||||
Both axes must be multiples of `vae_downscale × patch_size`:
|
||||
|
||||
| Models | Alignment | |
|
||||
|---|---|---|
|
||||
| Krea 2, Flux, SD3, Qwen-Image, Wan | **16** | 8 × 2 |
|
||||
| MiniMax H3 | **32** | 16 × 2 |
|
||||
| SDXL, SD1.5 (UNet, 3 downsample stages) | **64** | universally safe |
|
||||
|
||||
Miss it and `comfy.ldm.common_dit.pad_to_patch_size` pads the latent up to the patch
|
||||
grid using **circular** padding — the pad row is a wrapped copy of the *top* of the
|
||||
image. That fabricated row gets folded into the same patch token as the real bottom
|
||||
row, a combination the model never saw in training, so the final latent row decodes to
|
||||
garbage: a band along the bottom edge, 8 px tall on Krea 2, 16 px on H3. Padding is
|
||||
only ever appended, so the artifact is always bottom/right, never top/left.
|
||||
|
||||
This is why 1920×1080 bands on Krea 2 (1080/8 = 135 latent rows, odd) and 1920×1088
|
||||
doesn't (1088/8 = 136, even). The built-in selector defaults `multiple` to 8, which
|
||||
walks you straight into it.
|
||||
|
||||
## Reference resolutions
|
||||
|
||||
Krea 2 (alignment 16, ~2.36 MP — Turbo's sanctioned 1K–2K band):
|
||||
|
||||
| Ratio | Resolution | Real MP |
|
||||
|---|---|---|
|
||||
| 1:1 | 1536 × 1536 | 2.36 |
|
||||
| 16:9 | 2048 × 1152 | 2.36 |
|
||||
| 9:16 | 1152 × 2048 | 2.36 |
|
||||
| 3:2 | 1824 × 1216 | 2.22 |
|
||||
| 4:3 | 1728 × 1296 | 2.24 |
|
||||
| 21:9 | 2352 × 1008 | 2.37 |
|
||||
|
||||
MiniMax H3 (alignment 32; native canvas is 768 short edge / 1.03 MP, so treat anything
|
||||
above ~1.3 MP as extrapolation):
|
||||
|
||||
| Ratio | Resolution | Real MP |
|
||||
|---|---|---|
|
||||
| 16:9 | 1024 × 576 | 0.59 |
|
||||
| 16:9 | 1536 × 864 | 1.33 |
|
||||
| ~1.75:1 | 1344 × 768 | 1.03 (node default, exactly the trained canvas) |
|
||||
|
||||
Note that on a 32-grid, exact 16:9 only exists at `512n × 288n` — none of the commonly
|
||||
circulated H3 resolution tables are actually 16:9.
|
||||
|
||||
## Nodes
|
||||
|
||||
### Resolution Selector (Real MP)
|
||||
|
||||
**Inputs** — `aspect_ratio` (11 presets + Custom), `megapixels` (real, 10⁶),
|
||||
`alignment` (default 16), `exact_ratio` (default on), `custom_w` / `custom_h` (advanced,
|
||||
for the Custom ratio).
|
||||
|
||||
**Outputs** — `width`, `height`, `megapixels` (what it actually produced), `label`
|
||||
(`"1152x2048"`, useful for filename prefixes).
|
||||
|
||||
### Align Resolution to Grid
|
||||
|
||||
**Inputs** — `width`, `height`, `alignment`, `mode` (`round` / `up` / `down`).
|
||||
|
||||
**Outputs** — `width`, `height`, `changed` (true if anything was off-grid), `label`.
|
||||
|
||||
Use `up` when you must not lose framing, `down` when you must not exceed a budget.
|
||||
|
||||
## Install
|
||||
|
||||
Via ComfyUI-Manager, or:
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/dreevelle/ComfyUI-ResolutionTools
|
||||
```
|
||||
|
||||
No dependencies, no restart quirks — it's pure stdlib maths.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
"""ComfyUI-ResolutionTools.
|
||||
|
||||
Resolution maths for latent-space models, done exactly.
|
||||
|
||||
Two nodes:
|
||||
|
||||
``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.
|
||||
|
||||
``ResolutionAlignToGrid``
|
||||
Snap an arbitrary width/height (e.g. 1920x1080) to the same grid
|
||||
(-> 1920x1088), which is what stops the bottom-edge banding described below.
|
||||
|
||||
Why alignment matters
|
||||
---------------------
|
||||
A latent-space DiT reaches pixels through two reductions::
|
||||
|
||||
pixels --/ VAE spatial downscale --> latent --/ DiT patch size --> tokens
|
||||
|
||||
so both axes must be multiples of ``vae_downscale * patch_size``:
|
||||
|
||||
========================================== =========
|
||||
Krea 2 / Flux / SD3 / Qwen-Image / Wan 16 (8 x 2)
|
||||
MiniMax H3 32 (16 x 2)
|
||||
SDXL / SD1.5 (UNet, 3 downsample stages) 64
|
||||
========================================== =========
|
||||
|
||||
Miss it and ComfyUI's ``comfy.ldm.common_dit.pad_to_patch_size`` pads the latent
|
||||
up to the patch grid with **circular** padding -- the pad row is a wrapped copy
|
||||
of the top of the image. That fake row gets folded into the same patch token as
|
||||
the real bottom row, which the model never saw in training, so the final latent
|
||||
row decodes to a band of garbage at the bottom edge. One latent row = 8 px on
|
||||
Krea 2, 16 px on H3. Padding is only ever appended, so the artifact is always
|
||||
bottom/right, never top/left.
|
||||
|
||||
Self-contained on purpose (nothing imported from ``comfy_extras``) so a ComfyUI
|
||||
core update can't silently change the maths.
|
||||
"""
|
||||
|
||||
import math
|
||||
from enum import Enum
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from comfy_api.latest import ComfyExtension, IO
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aspect ratios. Values are (w, h) integer pairs, reduced before use.
|
||||
# ---------------------------------------------------------------------------
|
||||
class AspectRatio(str, Enum):
|
||||
SQUARE = "1:1 (Square)"
|
||||
PHOTO_V = "2:3 (Portrait Photo)"
|
||||
PHOTO_H = "3:2 (Photo)"
|
||||
STANDARD_V = "3:4 (Portrait Standard)"
|
||||
STANDARD_H = "4:3 (Standard)"
|
||||
SOCIAL_V = "4:5 (Portrait Social)"
|
||||
SOCIAL_H = "5:4 (Social)"
|
||||
WIDESCREEN_V = "9:16 (Portrait Widescreen)"
|
||||
WIDESCREEN_H = "16:9 (Widescreen)"
|
||||
ULTRAWIDE_V = "9:21 (Portrait Ultrawide)"
|
||||
ULTRAWIDE_H = "21:9 (Ultrawide)"
|
||||
CUSTOM = "Custom (use custom_w / custom_h)"
|
||||
|
||||
|
||||
ASPECT_RATIOS: dict[AspectRatio, tuple[int, int]] = {
|
||||
AspectRatio.SQUARE: (1, 1),
|
||||
AspectRatio.PHOTO_V: (2, 3),
|
||||
AspectRatio.PHOTO_H: (3, 2),
|
||||
AspectRatio.STANDARD_V: (3, 4),
|
||||
AspectRatio.STANDARD_H: (4, 3),
|
||||
AspectRatio.SOCIAL_V: (4, 5),
|
||||
AspectRatio.SOCIAL_H: (5, 4),
|
||||
AspectRatio.WIDESCREEN_V: (9, 16),
|
||||
AspectRatio.WIDESCREEN_H: (16, 9),
|
||||
AspectRatio.ULTRAWIDE_V: (9, 21),
|
||||
AspectRatio.ULTRAWIDE_H: (21, 9),
|
||||
}
|
||||
|
||||
|
||||
ALIGNMENT_TOOLTIP = (
|
||||
"Pixel grid both axes must land on = VAE spatial downscale x DiT patch size. "
|
||||
"16: Krea 2 / Flux / SD3 / Qwen-Image / Wan. "
|
||||
"32: MiniMax H3. "
|
||||
"64: SDXL / SD1.5, and universally safe. "
|
||||
"Getting this wrong causes a band of artifacts along the bottom/right edge."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core maths
|
||||
# ---------------------------------------------------------------------------
|
||||
def lattice_step(w_ratio: int, h_ratio: int, alignment: int) -> int:
|
||||
"""Smallest scale increment keeping an exact ratio on the alignment grid.
|
||||
|
||||
For a reduced ratio a:b, sizes are (a*s, b*s). ``a*s`` is a multiple of
|
||||
``m`` only when ``s`` is a multiple of ``m // gcd(a, m)``; same for ``b``.
|
||||
Both hold together on multiples of the lcm of those two, so the valid
|
||||
resolutions are exactly ``(a*L*n, b*L*n)`` for integer n >= 1.
|
||||
|
||||
9:16 at m=16 -> L=16 -> 144n x 256n (n=8 gives 1152x2048)
|
||||
16:9 at m=32 -> L=32 -> 512n x 288n (n=3 gives 1536x864)
|
||||
"""
|
||||
return math.lcm(alignment // math.gcd(w_ratio, alignment),
|
||||
alignment // math.gcd(h_ratio, alignment))
|
||||
|
||||
|
||||
def solve_exact(w_ratio: int, h_ratio: int, alignment: int, target_pixels: float) -> tuple[int, int]:
|
||||
"""Grid-aligned size with the ratio held exactly, area nearest the target."""
|
||||
step = lattice_step(w_ratio, h_ratio, alignment)
|
||||
# area(n) = w_ratio*h_ratio*step^2 * n^2, so solve for n and round.
|
||||
n = max(1, round(math.sqrt(target_pixels / (w_ratio * h_ratio * step * step))))
|
||||
return w_ratio * step * n, h_ratio * step * n
|
||||
|
||||
|
||||
def solve_nearest(w_ratio: int, h_ratio: int, alignment: int, target_pixels: float) -> tuple[int, int]:
|
||||
"""Grid-aligned size with each axis rounded independently.
|
||||
|
||||
Hits the area target more closely than :func:`solve_exact` at the cost of a
|
||||
small aspect-ratio drift. Useful when the exact lattice is coarse.
|
||||
"""
|
||||
scale = math.sqrt(target_pixels / (w_ratio * h_ratio))
|
||||
width = max(alignment, round(w_ratio * scale / alignment) * alignment)
|
||||
height = max(alignment, round(h_ratio * scale / alignment) * alignment)
|
||||
return width, height
|
||||
|
||||
|
||||
def align_value(value: int, alignment: int, mode: str) -> int:
|
||||
"""Snap one dimension onto the grid, never returning less than ``alignment``."""
|
||||
if mode == "up":
|
||||
snapped = math.ceil(value / alignment) * alignment
|
||||
elif mode == "down":
|
||||
snapped = math.floor(value / alignment) * alignment
|
||||
else:
|
||||
snapped = round(value / alignment) * alignment
|
||||
return max(alignment, snapped)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
class ResolutionSelectorMP(IO.ComfyNode):
|
||||
"""Aspect ratio + real megapixels -> grid-aligned width/height."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="ResolutionSelectorMP",
|
||||
display_name="Resolution Selector (Real MP)",
|
||||
category="utilities",
|
||||
description=(
|
||||
"Width/height from an aspect ratio and a real megapixel target "
|
||||
"(1 MP = 1,000,000 pixels), snapped to the model's patch grid "
|
||||
"with the aspect ratio held exactly."
|
||||
),
|
||||
inputs=[
|
||||
IO.Combo.Input(
|
||||
"aspect_ratio",
|
||||
options=AspectRatio,
|
||||
default=AspectRatio.WIDESCREEN_H,
|
||||
tooltip="Aspect ratio of the output. Portrait entries are listed before their landscape counterparts.",
|
||||
),
|
||||
IO.Float.Input(
|
||||
"megapixels",
|
||||
default=1.0,
|
||||
min=0.01,
|
||||
max=64.0,
|
||||
step=0.01,
|
||||
tooltip=(
|
||||
"Target area in real megapixels: 1.0 = 1,000,000 pixels. "
|
||||
"Note this is NOT the built-in Resolution Selector's unit, which is 1024x1024 = 1.049 MP."
|
||||
),
|
||||
),
|
||||
IO.Int.Input(
|
||||
"alignment",
|
||||
default=16,
|
||||
min=1,
|
||||
max=256,
|
||||
step=8,
|
||||
tooltip=ALIGNMENT_TOOLTIP,
|
||||
),
|
||||
IO.Boolean.Input(
|
||||
"exact_ratio",
|
||||
default=True,
|
||||
tooltip=(
|
||||
"On: hold the aspect ratio exactly by snapping to the lattice of valid sizes, "
|
||||
"accepting a slightly different area. Off: round each axis independently, "
|
||||
"hitting the area target more closely but drifting the ratio."
|
||||
),
|
||||
),
|
||||
IO.Int.Input(
|
||||
"custom_w",
|
||||
default=16,
|
||||
min=1,
|
||||
max=4096,
|
||||
tooltip="Width term of the ratio, used only when aspect_ratio is Custom.",
|
||||
advanced=True,
|
||||
),
|
||||
IO.Int.Input(
|
||||
"custom_h",
|
||||
default=9,
|
||||
min=1,
|
||||
max=4096,
|
||||
tooltip="Height term of the ratio, used only when aspect_ratio is Custom.",
|
||||
advanced=True,
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.Int.Output(display_name="width", tooltip="Width in pixels, a multiple of alignment."),
|
||||
IO.Int.Output(display_name="height", tooltip="Height in pixels, a multiple of alignment."),
|
||||
IO.Float.Output(display_name="megapixels", tooltip="Real megapixels actually produced."),
|
||||
IO.String.Output(display_name="label", tooltip='Dimensions as "1152x2048", handy for filename prefixes.'),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, aspect_ratio: str, megapixels: float, alignment: int,
|
||||
exact_ratio: bool, custom_w: int, custom_h: int) -> IO.NodeOutput:
|
||||
if aspect_ratio == AspectRatio.CUSTOM:
|
||||
w_ratio, h_ratio = custom_w, custom_h
|
||||
else:
|
||||
w_ratio, h_ratio = ASPECT_RATIOS[AspectRatio(aspect_ratio)]
|
||||
|
||||
# Reduce, so 16:9 and 32:18 give the same lattice.
|
||||
divisor = math.gcd(w_ratio, h_ratio)
|
||||
w_ratio, h_ratio = w_ratio // divisor, h_ratio // divisor
|
||||
|
||||
target_pixels = megapixels * 1_000_000
|
||||
solve = solve_exact if exact_ratio else solve_nearest
|
||||
width, height = solve(w_ratio, h_ratio, alignment, target_pixels)
|
||||
|
||||
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."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="ResolutionAlignToGrid",
|
||||
display_name="Align Resolution to Grid",
|
||||
category="utilities",
|
||||
description=(
|
||||
"Snap width/height to a multiple of the model's patch grid, e.g. "
|
||||
"1920x1080 -> 1920x1088 for Krea 2. Unaligned dimensions get "
|
||||
"circular-padded in the DiT and band along the bottom/right edge."
|
||||
),
|
||||
inputs=[
|
||||
IO.Int.Input("width", default=1920, min=1, max=16384),
|
||||
IO.Int.Input("height", default=1080, min=1, max=16384),
|
||||
IO.Int.Input("alignment", default=16, min=1, max=256, step=8, tooltip=ALIGNMENT_TOOLTIP),
|
||||
IO.Combo.Input(
|
||||
"mode",
|
||||
options=["round", "up", "down"],
|
||||
default="round",
|
||||
tooltip="round: nearest multiple. up: never shrink (may add letterbox). down: never grow (may crop framing).",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.Int.Output(display_name="width"),
|
||||
IO.Int.Output(display_name="height"),
|
||||
IO.Boolean.Output(display_name="changed", tooltip="True if either dimension was off the grid."),
|
||||
IO.String.Output(display_name="label"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, width: int, height: int, alignment: int, mode: str) -> IO.NodeOutput:
|
||||
new_w = align_value(width, alignment, mode)
|
||||
new_h = align_value(height, alignment, mode)
|
||||
changed = (new_w != width) or (new_h != height)
|
||||
return IO.NodeOutput(new_w, new_h, changed, f"{new_w}x{new_h}")
|
||||
|
||||
|
||||
class ResolutionToolsExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||
return [ResolutionSelectorMP, ResolutionAlignToGrid]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> ResolutionToolsExtension:
|
||||
return ResolutionToolsExtension()
|
||||
@@ -0,0 +1,17 @@
|
||||
[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"
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
# Pure stdlib maths; everything else is provided by ComfyUI core.
|
||||
dependencies = []
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/dreevelle/ComfyUI-ResolutionTools"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "dreevelle"
|
||||
DisplayName = "ComfyUI-ResolutionTools"
|
||||
Icon = ""
|
||||
Reference in New Issue
Block a user