feat(model_resolution_picker): add a resolution dropdown synced with the chip grid

- Add a native resolution COMBO (shipped sizes only) so a size can be picked
  and saved in the workflow without the panel; custom or unparseable labels
  fall back to width/height on both sides.
- Mirror the dropdown with the chips: options follow the active chip, the
  value tracks the current size, and picking a label writes width/height.
- Draw a decorative ratio shape on each ratio chip and show the first
  families warning in the status line.
This commit is contained in:
Vito Sansevero
2026-10-27 12:22:16 -07:00
parent 211d69e04e
commit eaf03874b3
12 changed files with 268 additions and 17 deletions
@@ -8,6 +8,7 @@ validation is a startup error.
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from .logic import size_label
from .override import current_table
from .table import (
CHIPS,
@@ -29,6 +30,7 @@ __all__ = [
"family_labels",
"resolve_family",
"families_payload",
"resolution_labels",
"SHIPPED_DATA",
"load_shipped_families",
]
@@ -63,6 +65,17 @@ def family_labels() -> List[str]:
return [family.label for family in FAMILIES]
def resolution_labels() -> List[str]:
"""Return the sorted distinct size labels across the shipped families."""
labels = {
size_label(width, height, chip)
for family in FAMILIES
for chip, sizes in family.sizes.items()
for width, height in sizes
}
return sorted(labels)
def resolve_family(label: str) -> Optional[Family]:
"""Return the family matching ``label`` or one of its aliases, else None."""
for family in _current()[0]:
@@ -1,6 +1,8 @@
"""Pure size math for the Model Resolution Picker node."""
import math
import re
from typing import Optional
MEGAPIXEL = 1_048_576
@@ -42,3 +44,22 @@ def ratio_label(width: int, height: int) -> str:
divisor = math.gcd(width, height)
label = f"{width // divisor}:{height // divisor}"
return _ALIASES.get(label, label)
_SIZE_LABEL = re.compile(r"^(\d+)×(\d+) - \S+ \(\d+\.\dMP\)$")
def size_label(width: int, height: int, ratio_text: str) -> str:
"""Return the dropdown label ``W×H - ratio (X.XMP)`` for a size."""
return f"{width}×{height} - {ratio_text} ({width * height / 1_000_000:.1f}MP)"
def parse_size_label(text: object) -> Optional[tuple[int, int]]:
"""Return ``(width, height)`` from a :func:`size_label` string, else None."""
if not isinstance(text, str):
return None
match = _SIZE_LABEL.match(text)
if match is None:
return None
width, height = int(match.group(1)), int(match.group(2))
return (width, height) if width > 0 and height > 0 else None
@@ -11,9 +11,10 @@ from .families import (
DEFAULT_FAMILY,
families_payload,
family_labels,
resolution_labels,
resolve_family,
)
from .logic import round_to_step
from .logic import parse_size_label, round_to_step
logger = logging.getLogger(__name__)
@@ -36,6 +37,14 @@ class ModelResolutionPickerNode(ComfyAssetsBaseNode):
"tooltip": "Model family; the chips list its preferred sizes",
},
),
"resolution": (
["custom"] + resolution_labels(),
{
"default": "custom",
"tooltip": "Sizes for the selected model and ratio; "
"custom uses width/height",
},
),
"width": (
"INT",
{
@@ -66,7 +75,7 @@ class ModelResolutionPickerNode(ComfyAssetsBaseNode):
}
@classmethod
def VALIDATE_INPUTS(cls, model: str) -> bool:
def VALIDATE_INPUTS(cls, model: str, resolution: str) -> bool:
"""Accept any label so renamed or future families still execute (create() falls back)."""
return True
@@ -76,9 +85,16 @@ class ModelResolutionPickerNode(ComfyAssetsBaseNode):
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
def create(
self, model: str, width: int, height: int, batch_size: int
self, model: str, resolution: str, width: int, height: int, batch_size: int
) -> Tuple[Dict[str, Any], int, int]:
"""Round the size to the family's step and build the empty latent."""
"""Round the size to the family's step and build the empty latent.
A listed ``resolution`` label overrides ``width``/``height``; ``custom``
or any label that does not parse leaves them alone.
"""
listed = parse_size_label(resolution) if resolution != "custom" else None
if listed is not None:
width, height = listed
family = resolve_family(model)
if family is None:
logger.warning(
+46
View File
@@ -1846,6 +1846,52 @@ test('resolution picker renders chips from the families route and writes sizes t
await expect(panel).toHaveAttribute('data-ca-theme', theme);
}
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
const box = async name => panel.getByRole('button', { name, exact: true }).locator('.ca-resolution-shape').evaluate(shape => {
const rect = shape.getBoundingClientRect();
return { wide: rect.width > rect.height, tall: rect.height > rect.width, hidden: shape.getAttribute('aria-hidden') };
});
expect(await box('16:9')).toMatchObject({ wide: true, hidden: 'true' });
expect(await box('9:16')).toMatchObject({ tall: true, hidden: 'true' });
await expect(panel.getByRole('button', { name: 'Custom ratio', exact: true }).locator('.ca-resolution-shape')).toHaveCount(0);
});
test('resolution picker mirrors the panel in a native resolution dropdown', async ({ page }) => {
await openHost(page);
await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', resolution: 'custom', width: 1024, height: 1024, batch_size: 1 });
const panel = page.locator('.ca-resolution-picker');
const node = page.locator('[data-node="ModelResolutionPicker"]');
const resolution = node.getByLabel('resolution', { exact: true });
await expect(panel.getByRole('button', { name: '1:1', exact: true })).toBeEnabled();
await expect(resolution).toHaveValue('1024×1024 - 1:1 (1.0MP)');
await panel.getByRole('button', { name: '3:2', exact: true }).click();
await expect(resolution).toHaveValue('1216×832 - 3:2 (1.0MP)');
await resolution.fill('1344×768 - 16:9 (1.0MP)');
await resolution.dispatchEvent('change');
await expect(node.getByLabel('width', { exact: true })).toHaveValue('1344');
await expect(node.getByLabel('height', { exact: true })).toHaveValue('768');
await expect(panel.getByRole('button', { name: '16:9', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.locator('.ca-dimension-swap').getByRole('button', { name: 'Swap width and height', exact: true }).click();
await expect(resolution).toHaveValue('768×1344 - 9:16 (1.0MP)');
await node.getByLabel('width', { exact: true }).fill('1000');
await node.getByLabel('width', { exact: true }).dispatchEvent('change');
await expect(resolution).toHaveValue('custom');
await resolution.fill('custom');
await resolution.dispatchEvent('change');
await expect(node.getByLabel('width', { exact: true })).toHaveValue('1000');
});
test('resolution picker shows the first families warning in the status line', async ({ page }) => {
await page.route('**/kikotools/model_resolution/families', async route => {
const response = await route.fetch();
const payload = await response.json();
await route.fulfill({ response, json: { ...payload, warnings: ['Override ignored: test'] } });
});
await openHost(page);
await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', resolution: 'custom', width: 1024, height: 1024, batch_size: 1 });
const panel = page.locator('.ca-resolution-picker');
await expect(panel.locator('.ca-resolution-status')).toHaveText('Override ignored: test');
await panel.getByRole('button', { name: '3:2', exact: true }).click();
await expect(panel.locator('.ca-resolution-status')).toHaveText('Override ignored: test');
});
test('resolution picker mounting is idempotent and removal disposes its scope', async ({ page }) => {
+4 -3
View File
@@ -86,17 +86,18 @@ test('resolution picker survives renderers, swap, themes and workflow reload', a
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
const saved = workflow.nodes.find(node => node.type === 'ModelResolutionPicker');
if (saved.widgets_values.slice(4).some(value => value !== '' && value != null)) throw new Error('Picker UI leaked into workflow values');
if (saved.widgets_values.slice(5).some(value => value !== '' && value != null)) throw new Error('Picker UI leaked into workflow values');
await app.loadGraphData(workflow);
const node = app.graph._nodes.find(node => node.type === 'ModelResolutionPicker');
return {
saved: saved.widgets_values.slice(0, 4),
restored: node.widgets.filter(widget => ['model', 'width', 'height', 'batch_size'].includes(widget.name)).map(widget => widget.value),
saved: saved.widgets_values.slice(0, 5),
restored: node.widgets.filter(widget => ['model', 'resolution', 'width', 'height', 'batch_size'].includes(widget.name)).map(widget => widget.value),
size: Array.from(node.size),
savedSize: Array.from(saved.size),
};
});
expect(restored.restored).toEqual(restored.saved);
expect(restored.restored[1]).toMatch(/^\d+×\d+ - 9:16 \(\d\.\dMP\)$/);
expect(restored.size).toEqual(restored.savedSize);
await expect(panel.getByRole('button', { name: '9:16', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.evaluate(async () => {
+20
View File
@@ -2,10 +2,13 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
defaultEntry,
dropdownOptions,
deriveSelection,
effectiveSize,
megapixels,
parseRatio,
parseSizeLabel,
sizeLabel,
sizesForRatio,
sizeOnModelChange,
} from '../../web/ui/resolution-picker.js';
@@ -59,3 +62,20 @@ test('model change keeps listed sizes, else the same chip default, else the fami
assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 1216, 832, '3:2'), [2048, 2048]);
assert.deepEqual(defaultEntry(payload.families[0], '21:9'), null);
});
test('size labels round-trip and mirror the Python format', () => {
assert.equal(sizeLabel(1216, 832, '3:2'), '1216×832 - 3:2 (1.0MP)');
assert.equal(sizeLabel(2560, 1536, '16:9'), '2560×1536 - 16:9 (3.9MP)');
assert.deepEqual(parseSizeLabel(sizeLabel(768, 1344, '9:16')), [768, 1344]);
for (const text of ['custom', 'garbage', '', null, undefined, '0×832 - 3:2 (1.0MP)']) assert.equal(parseSizeLabel(text), null);
});
test('dropdownOptions lists the active chip sizes, or every family size for custom', () => {
assert.deepEqual(dropdownOptions(payload, 'SDXL', '3:2'), ['custom', '1216×832 - 3:2 (1.0MP)']);
assert.deepEqual(dropdownOptions(payload, 'SDXL', null), [
'custom', '1024×1024 - 1:1 (1.0MP)', '832×1216 - 2:3 (1.0MP)', '1216×832 - 3:2 (1.0MP)',
'768×1344 - 9:16 (1.0MP)', '1344×768 - 16:9 (1.0MP)',
]);
assert.deepEqual(dropdownOptions(payload, 'SDXL', 'custom'), dropdownOptions(payload, 'SDXL', null));
assert.deepEqual(dropdownOptions(payload, 'SDXL', '21:9'), ['custom']);
assert.deepEqual(dropdownOptions(payload, 'nope', '3:2'), ['custom']);
assert.deepEqual(dropdownOptions(null, 'SDXL', null), ['custom']);
});
@@ -1,8 +1,10 @@
from kikotools.tools.model_resolution_picker.logic import (
MEGAPIXEL,
generate_size,
parse_size_label,
ratio_label,
round_to_step,
size_label,
)
@@ -27,3 +29,17 @@ def test_ratio_label_reduces_and_aliases():
assert ratio_label(1280, 800) == "16:10"
assert ratio_label(1344, 576) == "21:9"
assert MEGAPIXEL == 1024 * 1024
def test_size_label_formats_size_ratio_and_megapixels():
assert size_label(1216, 832, "3:2") == "1216×832 - 3:2 (1.0MP)"
assert size_label(2560, 1536, "16:9") == "2560×1536 - 16:9 (3.9MP)"
def test_parse_size_label_round_trips_and_rejects_garbage():
assert parse_size_label("1216×832 - 3:2 (1.0MP)") == (1216, 832)
assert parse_size_label(size_label(768, 1344, "9:16")) == (768, 1344)
assert parse_size_label("custom") is None
assert parse_size_label("garbage") is None
assert parse_size_label("0×832 - 3:2 (1.0MP)") is None
assert parse_size_label(None) is None
@@ -25,27 +25,70 @@ def test_inputs_and_outputs():
assert ModelResolutionPickerNode.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
def test_resolution_options_come_from_shipped_families():
options, config = ModelResolutionPickerNode.INPUT_TYPES()["required"]["resolution"]
assert options[0] == "custom"
assert config["default"] == "custom"
assert "1216×832 - 3:2 (1.0MP)" in options
assert options[1:] == sorted(set(options[1:]))
def test_resolution_input_sits_between_model_and_width():
assert list(ModelResolutionPickerNode.INPUT_TYPES()["required"])[:3] == [
"model",
"resolution",
"width",
]
def test_resolution_label_overrides_width_height():
_, w, h = ModelResolutionPickerNode().create(
"SDXL", "1216×832 - 3:2 (1.0MP)", 1024, 1024, 1
)
assert (w, h) == (1216, 832)
def test_custom_uses_width_height():
_, w, h = ModelResolutionPickerNode().create("SDXL", "custom", 1024, 768, 1)
assert (w, h) == (1024, 768)
def test_unparseable_resolution_behaves_like_custom():
_, w, h = ModelResolutionPickerNode().create("SDXL", "stale text", 1024, 768, 1)
assert (w, h) == (1024, 768)
def test_validate_inputs_accepts_unlisted_resolution():
assert ModelResolutionPickerNode.VALIDATE_INPUTS("SDXL", "9999×1 - 1:1 (0.0MP)")
def test_execution_rounds_to_family_step_and_marks_latent():
latent, w, h = ModelResolutionPickerNode().create("HunyuanImage 2.1", 70, 1000, 2)
latent, w, h = ModelResolutionPickerNode().create(
"HunyuanImage 2.1", "custom", 70, 1000, 2
)
assert (w, h) == (64, 992)
assert latent["samples"].shape == (2, 4, 124, 8)
assert latent["downscale_ratio_spacial"] == 8
def test_exact_sizes_pass_through():
_, w, h = ModelResolutionPickerNode().create("Flux.2 / Klein / MOSS", 1248, 832, 1)
_, w, h = ModelResolutionPickerNode().create(
"Flux.2 / Klein / MOSS", "custom", 1248, 832, 1
)
assert (w, h) == (1248, 832)
def test_unknown_label_keeps_size_and_warns(caplog):
with caplog.at_level(logging.WARNING):
_, w, h = ModelResolutionPickerNode().create("Renamed Family", 1003, 1003, 1)
_, w, h = ModelResolutionPickerNode().create(
"Renamed Family", "custom", 1003, 1003, 1
)
assert (w, h) == (1000, 1000) # fallback step 8
assert "Renamed Family" in caplog.text
def test_validate_inputs_accepts_any_label():
assert ModelResolutionPickerNode.VALIDATE_INPUTS("Renamed Family") is True
assert ModelResolutionPickerNode.VALIDATE_INPUTS("Renamed Family", "stale") is True
def test_alias_label_resolves_to_its_family():
@@ -54,7 +97,9 @@ def test_alias_label_resolves_to_its_family():
others = tuple(f for f in families.FAMILIES if f.label != "SDXL")
with mock.patch.object(families, "FAMILIES", (patched,) + others):
assert families.resolve_family("SDXL 1.0").label == "SDXL"
latent, w, h = ModelResolutionPickerNode().create("SDXL 1.0", 1216, 832, 1)
latent, w, h = ModelResolutionPickerNode().create(
"SDXL 1.0", "custom", 1216, 832, 1
)
assert (w, h) == (1216, 832)
assert latent["samples"].shape[-2:] == (104, 152)
+26 -3
View File
@@ -3,7 +3,7 @@ import { api } from '../../scripts/api.js';
import { createScope, mountTheme } from './ui/core.js';
import { swapDimensions } from './ui/dimensions.js';
import { mountDimensionSwap } from './ui/dimension-swap.js';
import { defaultEntry, deriveSelection, effectiveSize, megapixels, parseRatio, sizeOnModelChange, sizesForRatio } from './ui/resolution-picker.js';
import { defaultEntry, deriveSelection, dropdownOptions, effectiveSize, megapixels, parseRatio, parseSizeLabel, sizeLabel, sizeOnModelChange, sizesForRatio } from './ui/resolution-picker.js';
import { createResolutionPickerView } from './views/resolution-picker.js';
const FAMILIES_ROUTE = '/kikotools/model_resolution/families';
@@ -14,7 +14,7 @@ const RATIO_ERROR = 'Enter a ratio like 16:9';
const FALLBACK_STEP = 8;
const NODE_SIZE = [340, 600];
const MIN_PANEL_HEIGHT = 320;
const WRAPPED_WIDGETS = ['model', 'width', 'height'];
const WRAPPED_WIDGETS = ['model', 'resolution', 'width', 'height'];
let familiesPromise;
@@ -52,6 +52,7 @@ function mountPicker(node) {
let modelLabel = widgetOf(node, 'model')?.value;
let pendingModel = null;
let view;
let optionsKey = '';
const readSize = () => ['width', 'height'].map(name => Number(widgetOf(node, name)?.value));
@@ -95,10 +96,26 @@ function mountPicker(node) {
};
}
function syncResolution(state) {
const widget = widgetOf(node, 'resolution');
const selection = state.selection;
if (!widget || !selection) return;
const chip = state.customOpen || selection.custom ? null : selection.chip;
const key = `${selection.family?.label}|${chip}`;
if (key !== optionsKey) {
optionsKey = key;
widget.options.values = dropdownOptions(payload, selection.family?.label, chip);
}
const [width, height] = readSize();
const label = selection.entryIndex >= 0 ? sizeLabel(width, height, selection.chip) : 'custom';
widget.value = widget.options.values.includes(label) ? label : 'custom';
}
function render() {
if (scope.signal.aborted) return;
const state = buildState();
view.render(state);
syncResolution(state);
const swap = node.swapButtonWidget?.element?.querySelector('button');
if (swap) swap.disabled = state.wired;
}
@@ -189,7 +206,12 @@ function mountPicker(node) {
widget.callback = function () {
const result = callback?.apply(this, arguments);
if (writing) return result;
if (name === 'model') {
if (name === 'resolution') {
const size = isWiredIn(node, 'width') || isWiredIn(node, 'height')
? null : parseSizeLabel(arguments[0] ?? this.value);
if (size) writeSize(size[0], size[1]);
else render();
} else if (name === 'model') {
const label = arguments[0] ?? this.value;
if (!payload && !failed) {
pendingModel = { from: pendingModel ? pendingModel.from : modelLabel, to: label };
@@ -229,6 +251,7 @@ function mountPicker(node) {
loadFamilies(api).then(
loaded => {
payload = loaded;
view.setWarning(loaded.warnings?.[0] || '');
if (pendingModel) applyPendingModelChange();
else render();
},
+1
View File
@@ -235,6 +235,7 @@
.ca-resolution-header { margin: 0; font-size: 12px; color: var(--ca-muted); overflow-wrap: anywhere; }
.ca-resolution-chips { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.ca-resolution-chips button { min-inline-size: 0; padding-inline: 4px; white-space: normal; overflow-wrap: anywhere; }
.ca-resolution-shape { display: inline-block; border: 1px solid currentColor; border-radius: 2px; margin-inline-end: 6px; vertical-align: middle; }
.ca-resolution-sizes { display: flex; flex-direction: column; gap: 6px; }
.ca-resolution-sizes button { inline-size: 100%; }
.ca-resolution-picker :where(.ca-resolution-chips, .ca-resolution-sizes) button[aria-pressed="true"] { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
+21
View File
@@ -114,3 +114,24 @@ export function sizeOnModelChange(payload, newLabel, width, height, chip) {
if (listed) return [width, height];
return defaultEntry(family, chip) || family.default;
}
const SIZE_LABEL = /^(\d+)×(\d+) - \S+ \(\d+\.\dMP\)$/;
export function sizeLabel(width, height, ratioText) {
return `${width}×${height} - ${ratioText} (${((width * height) / 1000000).toFixed(1)}MP)`;
}
export function parseSizeLabel(text) {
const match = typeof text === 'string' ? SIZE_LABEL.exec(text) : null;
if (!match) return null;
const [width, height] = [Number(match[1]), Number(match[2])];
return width > 0 && height > 0 ? [width, height] : null;
}
export function dropdownOptions(payload, label, chip) {
const family = findFamily(payload, label);
if (!family) return ['custom'];
const chips = chip && chip !== 'custom' ? [chip] : payload.chips || [];
const labels = chips.flatMap((id) => (family.sizes[id] || []).map(([w, h]) => sizeLabel(w, h, id)));
return ['custom', ...new Set(labels)];
}
+30 -2
View File
@@ -9,6 +9,20 @@ function createToggle(text, pressed, disabled) {
return button;
}
const SHAPE_LONG_SIDE = 18;
const SHAPE_MIN_SIDE = 6;
function createShape(chipId) {
const [w, h] = chipId.split(':').map(Number);
const short = Math.max(SHAPE_MIN_SIDE, Math.round((SHAPE_LONG_SIDE * Math.min(w, h)) / Math.max(w, h)));
const shape = document.createElement('span');
shape.className = 'ca-resolution-shape';
shape.setAttribute('aria-hidden', 'true');
shape.style.width = `${w >= h ? SHAPE_LONG_SIDE : short}px`;
shape.style.height = `${h >= w ? SHAPE_LONG_SIDE : short}px`;
return shape;
}
export function createResolutionPickerView(scope, actions) {
const element = document.createElement('div');
element.className = 'ca-tools ca-resolution-picker';
@@ -48,11 +62,24 @@ export function createResolutionPickerView(scope, actions) {
});
scope.listen(input, 'change', () => actions.commitRatio(input.value));
let message = '';
let isMessageError = false;
let warning = '';
const showStatus = () => {
status.textContent = message || warning;
status.dataset.error = String(Boolean(message) && isMessageError);
};
return {
element, input,
setStatus(text, isError = false) {
status.textContent = text;
status.dataset.error = String(Boolean(isError));
message = text;
isMessageError = Boolean(isError);
showStatus();
},
setWarning(text) {
warning = text;
showStatus();
},
render(state) {
const focused = document.activeElement;
@@ -62,6 +89,7 @@ export function createResolutionPickerView(scope, actions) {
chips.replaceChildren(...state.chips.map(chip => {
const button = createToggle(chip.label, chip.active, state.disabled || chip.disabled);
button.dataset.chip = chip.id;
if (chip.id !== CUSTOM_CHIP) button.prepend(createShape(chip.id));
return button;
}));
input.hidden = !state.customOpen;