Files
ComfyAssets-ComfyUI-KikoTools/web/model_resolution_picker.js
T
Vito Sansevero f04f2584a8 fix(model_resolution_picker): refit on the next frame after a resize notification
- The ResizeObserver callback measured the panel and resized the node
  synchronously, which forces a layout while the stylesheet may still be
  applying; WebKit then reports "ResizeObserver loop completed with
  undelivered notifications" as a page error, and the browser fixture
  fails any test with a page error (CI WebKit job on PR #108).
- The callback now only schedules a single-flight requestAnimationFrame
  that measures and refits, so no observed box changes during delivery.
- Reproduced in the pinned Playwright WebKit image: the picker tests pass
  48/48 (16 tests × 3) with this change.
2026-10-27 14:54:11 -07:00

379 lines
16 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, dropdownOptions, effectiveSize, megapixels, modelOptions, parseRatio, parseSizeLabel, sizeLabel, 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_WIDTH = 340;
const FALLBACK_PANEL_HEIGHT = 320;
const FIT_RETRY_FRAMES = 60;
const FIT_SLACK = 1;
const LEGACY_VALUE_COUNT = 4;
const LEGACY_WIDGET_ORDER = ['model', 'width', 'height', 'batch_size'];
const WRAPPED_WIDGETS = ['model', 'resolution', '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;
let optionsKey = '';
let panelHeight = FALLBACK_PANEL_HEIGHT;
let fitRetryPending = false;
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 syncResolution(state) {
const widget = widgetOf(node, 'resolution');
if (!widget) return;
const selection = state.selection;
if (!selection) {
// Direct assignment fires no callback; a stale label must not override width/height.
widget.value = 'custom';
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 syncModelOptions() {
const widget = widgetOf(node, 'model');
if (!widget || !payload) return;
const values = modelOptions(payload, widget.value);
const current = widget.options.values;
if (!Array.isArray(current) || current.length !== values.length || current.some((item, index) => item !== values[index])) {
widget.options.values = values;
}
}
function render() {
if (scope.signal.aborted) return;
syncModelOptions();
const state = buildState();
view.render(state);
syncResolution(state);
const swap = node.swapButtonWidget?.element?.querySelector('button');
if (swap) swap.disabled = state.wired;
fitNodeHeight();
}
// Natural height of the panel: padding plus the visible children and the gaps between them.
// The element itself may be stretched by the host, so its own height is not the content height.
// Returns 0 when the element is detached or not laid out (hidden by the host while off-screen).
function measurePanel() {
const element = view.element;
if (!element.isConnected || element.offsetParent === null) return 0;
const style = getComputedStyle(element);
const visible = Array.from(element.children).filter(child => getComputedStyle(child).display !== 'none');
if (!visible.length || visible.every(child => child.offsetHeight === 0)) return 0;
const gap = parseFloat(style.rowGap) || 0;
const padding = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
return Math.ceil(padding + visible.reduce((sum, child) => sum + child.offsetHeight, 0) + gap * (visible.length - 1)) + FIT_SLACK;
}
// The panel reports its content height as both minimum and maximum widget height, so the node
// never shows empty space under the list; the node height then follows the host's computed size.
function fitNodeHeight(attempt = 0) {
if (scope.signal.aborted || (!payload && !failed)) return;
const measured = measurePanel();
if (!measured) {
// Not attached to the document yet (fresh mount, workflow load): retry on the next frames.
if (attempt < FIT_RETRY_FRAMES && !fitRetryPending && typeof requestAnimationFrame === 'function') {
fitRetryPending = true;
requestAnimationFrame(() => {
fitRetryPending = false;
fitNodeHeight(attempt + 1);
});
}
return;
}
panelHeight = measured;
const target = node.computeSize?.()?.[1];
if (!Number.isFinite(target) || Math.abs(target - node.size[1]) < 1) return;
node.setSize([node.size[0], target]);
node.graph?.setDirtyCanvas?.(true, true);
}
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.addDOMWidget('resolution_picker', 'div', view.element, {
serialize: false, hideOnZoom: false, getMinHeight: () => panelHeight, getMaxHeight: () => panelHeight,
});
node.setSize([NODE_WIDTH, node.computeSize?.()?.[1] ?? node.size[1]]);
// The stylesheet, fonts or a wrapped status line can change the content height after a render
// (the first fit may even see an unstyled panel): refit whenever a child's box changes. The
// children are created once by the view, so observing them at mount covers every render.
// The refit runs on the next frame, never inside the observer delivery: measuring there forces a
// layout while the stylesheet may be applying, and WebKit then reports an undelivered-notifications
// loop error for the observed children.
if (typeof ResizeObserver === 'function') {
let refitScheduled = false;
const observer = new ResizeObserver(() => {
if (scope.signal.aborted || refitScheduled) return;
refitScheduled = true;
requestAnimationFrame(() => {
refitScheduled = false;
if (scope.signal.aborted) return;
const measured = measurePanel();
if (measured && measured !== panelHeight) fitNodeHeight();
});
});
for (const child of view.element.children) observer.observe(child);
scope.cleanup(() => observer.disconnect());
}
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 === '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 };
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);
};
// Workflows saved before the resolution widget carry four values
// (model, width, height, batch_size) and load shifted by one slot.
const onConfigure = node.onConfigure;
node.onConfigure = function (info) {
const result = onConfigure?.apply(this, arguments);
const values = info?.widgets_values;
if (Array.isArray(values) && values.length === LEGACY_VALUE_COUNT) {
LEGACY_WIDGET_ORDER.forEach((name, index) => {
const widget = widgetOf(node, name);
if (widget) widget.value = values[index];
});
const resolution = widgetOf(node, 'resolution');
if (resolution) resolution.value = 'custom';
modelLabel = widgetOf(node, 'model')?.value;
render();
}
return result;
};
const onConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function () {
const result = onConnectionsChange?.apply(this, arguments);
render();
return result;
};
render();
loadFamilies(api).then(
loaded => {
payload = loaded;
view.setWarning(loaded.warnings?.[0] || '');
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;
};
},
});