import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; const WIDGETS_PER_ENTRY = 6; // header, ckpt, cfg_min, cfg_max, weight, enabled const NODE_CLASS = "ModelRandomizer"; const ANCHOR_WIDGET = "action_separator"; let cachedCheckpoints = null; async function fetchCheckpoints(forceRefresh = false) { if (cachedCheckpoints && !forceRefresh) return cachedCheckpoints; try { const resp = await api.fetchApi("/models/checkpoints"); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); cachedCheckpoints = await resp.json(); } catch (e) { console.warn("[ModelRandomizer] Failed to fetch checkpoints:", e); cachedCheckpoints = []; } return cachedCheckpoints; } // ── Drawing Helpers ──────────────────────────────────────────────── function isLowQuality() { return (app.canvas?.ds?.scale || 1) <= 0.5; } function drawRoundedRect(ctx, x, y, w, h, radius, fill, stroke) { ctx.beginPath(); ctx.roundRect(x, y, w, h, [radius]); if (fill) { ctx.fillStyle = fill; ctx.fill(); } if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.stroke(); } } function drawStyledButton(ctx, x, y, w, h, label, isPressed, colors) { const radius = 4; const lowQ = isLowQuality(); // Shadow if (!lowQ && !isPressed) { drawRoundedRect(ctx, x + 1, y + 1, w, h, radius, "#00000055"); } // Main fill + border const offsetY = isPressed ? 1 : 0; drawRoundedRect( ctx, x, y + offsetY, w, h, radius, isPressed ? colors.pressed : colors.bg, lowQ ? null : colors.border ); if (lowQ) return; // Text ctx.save(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = colors.text; ctx.fillText(label, x + w / 2, y + h / 2 + offsetY); ctx.restore(); } function drawSmallButton(ctx, x, y, w, h, label, isPressed, disabled, colors) { const radius = 3; const lowQ = isLowQuality(); const c = colors || {}; const bgColor = c.bg || "#4a4a4a"; const pressedColor = c.pressed || "#3a3a3a"; const borderColor = c.border || "#666"; const textColor = c.text || "#ddd"; const textPressedColor = c.textPressed || "#ccc"; const disabledBg = c.disabledBg || "#2a2a2a"; const disabledBorder = c.disabledBorder || "#3a3a3a"; const disabledText = c.disabledText || "#555"; if (disabled) { drawRoundedRect(ctx, x, y, w, h, radius, disabledBg, lowQ ? null : disabledBorder); if (!lowQ) { ctx.save(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = disabledText; ctx.font = "11px Arial"; ctx.fillText(label, x + w / 2, y + h / 2); ctx.restore(); } return; } if (!lowQ && !isPressed) { drawRoundedRect(ctx, x + 0.5, y + 0.5, w, h, radius, "#00000033"); } const offsetY = isPressed ? 1 : 0; drawRoundedRect( ctx, x, y + offsetY, w, h, radius, isPressed ? pressedColor : bgColor, lowQ ? null : borderColor ); if (lowQ) return; ctx.save(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = isPressed ? textPressedColor : textColor; ctx.font = "11px Arial"; ctx.fillText(label, x + w / 2, y + h / 2 + offsetY); ctx.restore(); } // ── Custom Widgets ───────────────────────────────────────────────── function createDividerWidget(name, opts = {}) { const marginTop = opts.marginTop ?? 4; const marginBottom = opts.marginBottom ?? 4; const thickness = opts.thickness ?? 1; const color = opts.color ?? LiteGraph.WIDGET_OUTLINE_COLOR; return { name, type: "custom", value: "", y: 0, last_y: 0, options: { serialize: false }, draw(ctx, node, width, y, height) { this.last_y = y; if (isLowQuality() || !thickness) return; const margin = 15; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = thickness; ctx.beginPath(); ctx.moveTo(margin, y + marginTop); ctx.lineTo(width - margin, y + marginTop); ctx.stroke(); ctx.restore(); }, mouse() { return false; }, computeSize(width) { return [width, marginTop + marginBottom + thickness]; }, serializeValue() { return null; }, }; } function createEntryHeaderWidget(index, onMoveUp, onMoveDown, onDelete) { return { name: `header_${index}`, type: "custom", value: "", y: 0, last_y: 0, options: { serialize: false }, _entryIndex: index, _upPressed: false, _downPressed: false, _deletePressed: false, _upBounds: null, _downBounds: null, _deleteBounds: null, _mouseDownTarget: null, draw(ctx, node, width, y, height) { this.last_y = y; const margin = 15; const lowQ = isLowQuality(); // Top divider line if (!lowQ) { ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(margin, y + 4); ctx.lineTo(width - margin, y + 4); ctx.stroke(); } if (lowQ) return; // Vertical center for label and buttons (shifted down past divider) const centerY = y + height * 0.5 + 4; // Label ctx.save(); ctx.fillStyle = "#999"; ctx.font = "11px Arial"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(`Model ${this._entryIndex}`, margin + 2, centerY); ctx.restore(); // Button layout (right-aligned): [▲] [▼] [✕] const btnH = 16; const btnW = 20; const btnGap = 4; const deleteSeparation = 10; const btnY = centerY - btnH / 2; const isFirst = this._entryIndex === 1; const isLast = this._entryIndex === node._modelCount; const isOnly = node._modelCount <= 1; const deleteColors = { bg: "#5a2a2a", pressed: "#4a1a1a", border: "#8a3a3a", text: "#e66", textPressed: "#c55", }; // ✕ Delete (rightmost) const delX = width - margin - btnW; this._deleteBounds = [delX, btnY, btnW, btnH]; drawSmallButton(ctx, delX, btnY, btnW, btnH, "\u2715", this._deletePressed, isOnly, deleteColors); // ▼ Move Down const downX = delX - btnW - deleteSeparation; this._downBounds = [downX, btnY, btnW, btnH]; drawSmallButton(ctx, downX, btnY, btnW, btnH, "\u25BC", this._downPressed, isLast); // ▲ Move Up const upX = downX - btnW - btnGap; this._upBounds = [upX, btnY, btnW, btnH]; drawSmallButton(ctx, upX, btnY, btnW, btnH, "\u25B2", this._upPressed, isFirst); }, _hitTest(pos) { const tests = [ ["up", this._upBounds], ["down", this._downBounds], ["delete", this._deleteBounds], ]; for (const [name, bounds] of tests) { if (bounds && pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[2] && pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3]) { return name; } } return null; }, _isDisabled(target, node) { if (target === "up") return this._entryIndex === 1; if (target === "down") return this._entryIndex === node._modelCount; if (target === "delete") return node._modelCount <= 1; return false; }, mouse(event, pos, node) { if (event.type === "pointerdown") { const target = this._hitTest(pos); if (!target || this._isDisabled(target, node)) return false; this._mouseDownTarget = target; if (target === "up") this._upPressed = true; if (target === "down") this._downPressed = true; if (target === "delete") this._deletePressed = true; app.graph.setDirtyCanvas(true, false); return true; } if (event.type === "pointerup") { const wasTarget = this._mouseDownTarget; this._upPressed = false; this._downPressed = false; this._deletePressed = false; this._mouseDownTarget = null; app.graph.setDirtyCanvas(true, false); if (!wasTarget) return false; const target = this._hitTest(pos); if (target === wasTarget && !this._isDisabled(target, node)) { if (target === "up") onMoveUp.call(node, this._entryIndex); if (target === "down") onMoveDown.call(node, this._entryIndex); if (target === "delete") onDelete.call(node, this._entryIndex); } return true; } if (event.type === "pointermove") { if (!this._mouseDownTarget) return false; const target = this._hitTest(pos); this._upPressed = (this._mouseDownTarget === "up" && target === "up"); this._downPressed = (this._mouseDownTarget === "down" && target === "down"); this._deletePressed = (this._mouseDownTarget === "delete" && target === "delete"); app.graph.setDirtyCanvas(true, false); return true; } return false; }, computeSize(width) { return [width, LiteGraph.NODE_WIDGET_HEIGHT + 12]; }, serializeValue() { return null; }, }; } function createAddButtonWidget(addCallback) { return { name: "model_actions", type: "custom", value: "", y: 0, last_y: 0, options: { serialize: false }, _pressed: false, _bounds: null, _mouseDown: false, draw(ctx, node, width, y, height) { this.last_y = y; const margin = 15; const w = width - margin * 2; this._bounds = [margin, y, w, height]; drawStyledButton(ctx, margin, y, w, height, "\u2795 Add Model", this._pressed, { bg: "#2a4a2a", pressed: "#1a3a1a", border: "#3a6a3a", text: "#9fcf9f" }); }, _isInside(pos) { const b = this._bounds; return b && pos[0] >= b[0] && pos[0] <= b[0] + b[2] && pos[1] >= b[1] && pos[1] <= b[1] + b[3]; }, mouse(event, pos, node) { if (event.type === "pointerdown") { if (!this._isInside(pos)) return false; this._mouseDown = true; this._pressed = true; app.graph.setDirtyCanvas(true, false); return true; } if (event.type === "pointerup") { const wasDown = this._mouseDown; this._pressed = false; this._mouseDown = false; app.graph.setDirtyCanvas(true, false); if (!wasDown) return false; if (this._isInside(pos)) addCallback.call(node); return true; } if (event.type === "pointermove") { if (!this._mouseDown) return false; this._pressed = this._isInside(pos); app.graph.setDirtyCanvas(true, false); return true; } return false; }, computeSize(width) { return [width, LiteGraph.NODE_WIDGET_HEIGHT]; }, serializeValue() { return null; }, }; } // ── Resize Helper ────────────────────────────────────────────────── function resizeNodeToFit(node) { const sz = node.computeSize(); node.setSize([Math.max(node.size[0], sz[0]), sz[1]]); } // ── Entry Manipulation ───────────────────────────────────────────── function swapModelEntries(node, idxA, idxB) { const fields = ["ckpt", "cfg_min", "cfg_max", "weight", "enabled"]; for (const field of fields) { const wA = node.widgets.find((w) => w.name === `${field}_${idxA}`); const wB = node.widgets.find((w) => w.name === `${field}_${idxB}`); if (wA && wB) { const tmp = wA.value; wA.value = wB.value; wB.value = tmp; } } } function removeModelEntryAt(node, targetIndex) { if (node._modelCount <= 1) return false; const prefixes = ["header", "ckpt", "cfg_min", "cfg_max", "weight", "enabled"]; for (const prefix of prefixes) { const idx = node.widgets.findIndex((w) => w.name === `${prefix}_${targetIndex}`); if (idx >= 0) node.widgets.splice(idx, 1); } for (let i = targetIndex + 1; i <= node._modelCount; i++) { const newIdx = i - 1; for (const prefix of prefixes) { const w = node.widgets.find((w) => w.name === `${prefix}_${i}`); if (w) { w.name = `${prefix}_${newIdx}`; if (prefix === "header") { w._entryIndex = newIdx; } } } } node._modelCount--; node.properties = node.properties || {}; node.properties.modelCount = node._modelCount; return true; } // ── Entry Action Callbacks (called with `this` = node) ───────────── function handleMoveUp(entryIndex) { if (entryIndex <= 1) return; swapModelEntries(this, entryIndex, entryIndex - 1); app.graph.setDirtyCanvas(true, true); } function handleMoveDown(entryIndex) { if (entryIndex >= this._modelCount) return; swapModelEntries(this, entryIndex, entryIndex + 1); app.graph.setDirtyCanvas(true, true); } function handleDeleteEntry(entryIndex) { if (removeModelEntryAt(this, entryIndex)) { resizeNodeToFit(this); app.graph.setDirtyCanvas(true, true); } } // ── Core Widget Management ───────────────────────────────────────── function addModelEntry(node, index, checkpoints, defaults) { const ckptDefault = defaults?.ckpt ?? checkpoints[0] ?? ""; const cfgMinDefault = defaults?.cfg_min ?? 5.0; const cfgMaxDefault = defaults?.cfg_max ?? 8.0; const weightDefault = defaults?.weight ?? 1.0; const enabledDefault = defaults?.enabled ?? true; const anchorIdx = node.widgets.findIndex((w) => w.name === ANCHOR_WIDGET); const newWidgets = []; newWidgets.push( node.addCustomWidget( createEntryHeaderWidget(index, handleMoveUp, handleMoveDown, handleDeleteEntry) ) ); newWidgets.push( node.addWidget("combo", `ckpt_${index}`, ckptDefault, () => {}, { values: checkpoints, }) ); newWidgets.push( node.addWidget("number", `cfg_min_${index}`, cfgMinDefault, () => {}, { min: 0.0, max: 100.0, step: 5, precision: 1, }) ); newWidgets.push( node.addWidget("number", `cfg_max_${index}`, cfgMaxDefault, () => {}, { min: 0.0, max: 100.0, step: 5, precision: 1, }) ); newWidgets.push( node.addWidget("number", `weight_${index}`, weightDefault, () => {}, { min: 0.0, max: 10.0, step: 1, precision: 1, }) ); newWidgets.push( node.addWidget("toggle", `enabled_${index}`, enabledDefault, () => {}, {}) ); if (anchorIdx >= 0) { for (let i = 0; i < newWidgets.length; i++) { const w = newWidgets[i]; const currentIdx = node.widgets.indexOf(w); if (currentIdx >= 0) { node.widgets.splice(currentIdx, 1); node.widgets.splice(anchorIdx + i, 0, w); } } } return newWidgets; } // ── Extension Registration ───────────────────────────────────────── app.registerExtension({ name: "comfyui.ModelRandomizer", async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name !== NODE_CLASS) return; const checkpoints = await fetchCheckpoints(); // --- onNodeCreated: set up initial widgets --- const origOnNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { origOnNodeCreated?.apply(this, arguments); this._modelCount = 0; this.addCustomWidget(createDividerWidget(ANCHOR_WIDGET)); this.addCustomWidget(createAddButtonWidget( // Add Model callback async function () { const freshCheckpoints = await fetchCheckpoints(true); this._modelCount++; addModelEntry(this, this._modelCount, freshCheckpoints); this.properties = this.properties || {}; this.properties.modelCount = this._modelCount; resizeNodeToFit(this); app.graph.setDirtyCanvas(true, true); } )); // Default first entry this._modelCount++; addModelEntry(this, this._modelCount, checkpoints); this.properties = this.properties || {}; this.properties.modelCount = this._modelCount; this.setSize(this.computeSize()); }; const origOnSerialize = nodeType.prototype.onSerialize; nodeType.prototype.onSerialize = function (o) { origOnSerialize?.apply(this, arguments); const entries = []; for (let i = 1; i <= this._modelCount; i++) { entries.push({ ckpt: this.widgets.find(w => w.name === `ckpt_${i}`)?.value, cfg_min: this.widgets.find(w => w.name === `cfg_min_${i}`)?.value, cfg_max: this.widgets.find(w => w.name === `cfg_max_${i}`)?.value, weight: this.widgets.find(w => w.name === `weight_${i}`)?.value, enabled: this.widgets.find(w => w.name === `enabled_${i}`)?.value, }); } o.properties = o.properties || {}; o.properties.modelEntries = entries; o.properties.modelCount = this._modelCount; }; const origConfigure = nodeType.prototype.configure; nodeType.prototype.configure = function (info) { // Nuke model widgets BEFORE super.configure so LiteGraph's // widgets_values restoration has nothing to mismatch against. // Includes both header_ (current) and sep_ (legacy) prefixes. for (let i = this.widgets.length - 1; i >= 0; i--) { const n = this.widgets[i]?.name; if (n && (n.startsWith("header_") || n.startsWith("sep_") || n.startsWith("ckpt_") || n.startsWith("cfg_min_") || n.startsWith("cfg_max_") || n.startsWith("weight_") || n.startsWith("enabled_"))) { this.widgets.splice(i, 1); } } this._modelCount = 0; origConfigure?.apply(this, arguments); const entries = info.properties?.modelEntries || []; const savedCount = entries.length || info.properties?.modelCount || 0; for (let i = 0; i < savedCount; i++) { this._modelCount++; addModelEntry(this, this._modelCount, checkpoints, entries[i]); } resizeNodeToFit(this); app.graph.setDirtyCanvas(true, true); }; }, });