commit 2b7a1fecaebc626e104f55543075f3ee4560a89f Author: dreevelle <123705989+dreevelle@users.noreply.github.com> Date: Sun Aug 9 13:51:36 2026 +0100 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 Claude-Session: https://claude.ai/code/session_01CdgKVDqEbD14EJ4RC3GPmg diff --git a/.comfyignore b/.comfyignore new file mode 100644 index 0000000..f5119aa --- /dev/null +++ b/.comfyignore @@ -0,0 +1,3 @@ +# Files kept in git but not shipped in the registry package. +CLAUDE.md +.github/ diff --git a/.github/workflows/publish_action.yml b/.github/workflows/publish_action.yml new file mode 100644 index 0000000..df9c382 --- /dev/null +++ b/.github/workflows/publish_action.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b32bef7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +.DS_Store +*.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f33e59b --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..361512c --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..be16bec --- /dev/null +++ b/README.md @@ -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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..374be67 --- /dev/null +++ b/__init__.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c5fb68d --- /dev/null +++ b/pyproject.toml @@ -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 = ""