Files
ComfyAssets-ComfyUI-KikoTools/web/model_resolution_picker.js
T
Vito Sansevero 38f820c8f5 fix(model_resolution_picker): accept renamed labels, dedupe chip sizes, apply early model change
- VALIDATE_INPUTS lets unknown family labels reach create()
- no size is listed under two chips; SD 1.5 3:4 gets true 3:4 sizes
- model change before the families payload loads is applied on load
- custom ratio: clear error on chip click, cap sides at 8192, accept X
- real-host assertions for model change and wired-in state
2026-10-27 11:02:45 -07:00

256 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from '../../scripts/app.js';
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 { createResolutionPickerView } from './views/resolution-picker.js';
const FAMILIES_ROUTE = '/kikotools/model_resolution/families';
const CUSTOM_CHIP = 'custom';
const CHIP_IDS = ['1:1', '4:5', '5:4', '3:4', '4:3', '2:3', '3:2', '9:16', '16:9', '21:9', '9:21'];
const UNAVAILABLE_STATUS = 'Sizes unavailable; type width and height';
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'];
let familiesPromise;
function loadFamilies(client) {
if (!familiesPromise) {
familiesPromise = client.fetchApi(FAMILIES_ROUTE)
.then(response => {
if (!response.ok) throw new Error(`Families request failed: ${response.status}`);
return response.json();
})
.catch(error => {
familiesPromise = undefined;
throw error;
});
}
return familiesPromise;
}
function isWiredIn(node, name) {
return Boolean(node.inputs?.some(input => input.widget?.name === name && input.link != null));
}
function widgetOf(node, name) {
return node.widgets?.find(widget => widget.name === name);
}
function mountPicker(node) {
const scope = createScope();
node.resolutionScope = scope;
node.resolutionCustom = { open: false, text: '' };
let payload = null;
let failed = false;
let writing = false;
let lastChip = null;
let modelLabel = widgetOf(node, 'model')?.value;
let pendingModel = null;
let view;
const readSize = () => ['width', 'height'].map(name => Number(widgetOf(node, name)?.value));
function buildState() {
const [width, height] = readSize();
const wired = isWiredIn(node, 'width') || isWiredIn(node, 'height');
const model = widgetOf(node, 'model')?.value;
const staticChips = () => [...CHIP_IDS, CUSTOM_CHIP].map(id => ({
id, label: id === CUSTOM_CHIP ? 'Custom ratio' : id, active: false, disabled: true,
}));
const empty = { disabled: true, chips: staticChips(), customOpen: false, ratioText: '', entries: [] };
if (wired) return { ...empty, wired, header: 'Dimensions are wired in' };
if (failed || (payload && !Number.isFinite(width + height))) {
const [w, h] = effectiveSize(null, width, height);
return { ...empty, header: `${w} × ${h}` };
}
if (!payload) return { ...empty, header: 'Loading sizes…' };
const selection = deriveSelection(payload, model, width, height);
const { family, chip } = selection;
lastChip = chip;
const [w, h] = effectiveSize(family, width, height);
const custom = node.resolutionCustom;
const ratio = custom.open ? parseRatio(custom.text) : null;
let list = [];
if (family && custom.open) list = ratio ? sizesForRatio(family, ratio) : [];
else if (family && chip) list = family.sizes[chip] || [];
const showCustom = custom.open || selection.custom;
const chips = CHIP_IDS.map(id => ({
id, label: id, active: !showCustom && chip === id, disabled: !family?.sizes[id]?.length,
}));
chips.push({ id: CUSTOM_CHIP, label: 'Custom ratio', active: showCustom, disabled: !family });
return {
wired: false,
disabled: false,
header: `${w} × ${h} · ${chip || selection.ratioText} · ${megapixels(w, h)} MP`,
chips,
customOpen: custom.open,
ratioText: custom.text,
entries: list.map(([ew, eh]) => ({ w: ew, h: eh, active: ew === width && eh === height })),
selection,
};
}
function render() {
if (scope.signal.aborted) return;
const state = buildState();
view.render(state);
const swap = node.swapButtonWidget?.element?.querySelector('button');
if (swap) swap.disabled = state.wired;
}
function writeSize(w, h) {
const width = widgetOf(node, 'width');
const height = widgetOf(node, 'height');
if (!width || !height || writing) return;
writing = true;
try {
width.value = w;
height.value = h;
for (const widget of [width, height]) widget.callback?.(widget.value, app.canvas, node, [0, 0], null);
node.graph?.setDirtyCanvas?.(true, true);
} finally {
writing = false;
}
render();
}
const currentSelection = () => {
const [width, height] = readSize();
return deriveSelection(payload, widgetOf(node, 'model')?.value, width, height);
};
function applyModelChange(label) {
const [width, height] = readSize();
const wired = isWiredIn(node, 'width') || isWiredIn(node, 'height');
if (!payload || wired || !Number.isFinite(width + height)) return render();
const [w, h] = sizeOnModelChange(payload, label, width, height, lastChip);
if (w !== width || h !== height) writeSize(w, h);
else render();
}
function applyPendingModelChange() {
const pending = pendingModel;
pendingModel = null;
if (!pending || scope.signal.aborted || widgetOf(node, 'model')?.value !== pending.to) return;
const [width, height] = readSize();
const { chip } = deriveSelection(payload, pending.from, width, height);
lastChip = chip;
applyModelChange(pending.to);
}
const actions = {
selectChip(chip) {
if (!payload) return;
view.setStatus('');
node.resolutionCustom.open = false;
const [width, height] = readSize();
const { family } = currentSelection();
const entry = family && defaultEntry(family, chip);
const listed = family?.sizes[chip]?.some(([w, h]) => w === width && h === height);
if (!entry || listed) return render();
writeSize(entry[0], entry[1]);
},
openCustom() {
if (!payload) return;
node.resolutionCustom = { open: true, text: currentSelection().ratioText };
render();
},
commitRatio(text) {
const custom = node.resolutionCustom;
if (!parseRatio(text)) {
view.setStatus(RATIO_ERROR, true);
return;
}
view.setStatus('');
if (text.trim() === custom.text) return;
custom.text = text.trim();
render();
},
selectSize: writeSize,
};
view = createResolutionPickerView(scope, actions);
node.setSize(NODE_SIZE);
node.addDOMWidget('resolution_picker', 'div', view.element, {
serialize: false, hideOnZoom: false, getMinHeight: () => MIN_PANEL_HEIGHT,
});
mountTheme(view.element, app.ui.settings, scope);
scope.cleanup(() => view.element.remove());
for (const name of WRAPPED_WIDGETS) {
const widget = widgetOf(node, name);
if (!widget) continue;
const callback = widget.callback;
widget.callback = function () {
const result = callback?.apply(this, arguments);
if (writing) return result;
if (name === 'model') {
const label = arguments[0] ?? this.value;
if (!payload && !failed) {
pendingModel = { from: pendingModel ? pendingModel.from : modelLabel, to: label };
modelLabel = label;
render();
} else {
modelLabel = label;
applyModelChange(label);
}
}
else render();
return result;
};
}
node.swapDimensions = () => {
swapDimensions(node, app);
render();
};
node.createSwapButton = () => mountDimensionSwap(node, app);
node.createSwapButton();
const onRemoved = node.onRemoved;
node.onRemoved = function () {
scope.dispose();
return onRemoved?.apply(this, arguments);
};
const onConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function () {
const result = onConnectionsChange?.apply(this, arguments);
render();
return result;
};
render();
loadFamilies(api).then(
loaded => {
payload = loaded;
if (pendingModel) applyPendingModelChange();
else render();
},
() => {
if (scope.signal.aborted) return;
failed = true;
view.setStatus(UNAVAILABLE_STATUS, true);
render();
},
);
}
app.registerExtension({
name: 'comfyassets.ModelResolutionPicker',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'ModelResolutionPicker') return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
mountPicker(this);
return result;
};
},
});