diff --git a/web/js/Advanced_nodes.js b/web/js/Advanced_nodes.js new file mode 100644 index 0000000..2956d39 --- /dev/null +++ b/web/js/Advanced_nodes.js @@ -0,0 +1,275 @@ +/** + * AILab - Collapsible Advanced Options Extension + * Fully compatible with both WebUI 1.0 (LiteGraph) and WebUI 2.0 (Vue Canvas). + * + * This integration script is licensed under the GNU General Public License v3.0 (GPL-3.0). + * If you incorporate or modify this code, please credit AILab as the original source: + * https://github.com/1038lab + */ + +import { app } from "/scripts/app.js"; + +const EXTENSION_NAME = "AILab.RMBG.AdvancedToggle"; + +const NODE_CONFIG = { + "AILab_Yolo": [ + "model", + "mask_count", + "select_mask_index", + "unload_model" + ], + "AILab_LoadImageAdvanced": [ + "image_path_or_URL", + "image", + "upload" + ] +}; +const TARGET_NODES = Object.keys(NODE_CONFIG); + +function getNodeClass(node) { + return node?.comfyClass || node?.type || ""; +} + +function isPreviewWidget(w) { + return !!w && (w.name === "$$canvas-image-preview" || w.type === "IMAGE_PREVIEW" || (typeof w.name === "string" && w.name.toLowerCase().includes("preview"))); +} + +function hideWidget(w) { + if (!w) return; + if (!w._origType) { + w._origType = w.type; + w._origComputeSize = w.computeSize; + } + w.type = "hidden"; + w.hidden = true; + w.options = w.options || {}; + w.options.hidden = true; + w.computeSize = () => [0, -4]; + if (w.element) w.element.style.display = "none"; + if (w.inputEl) w.inputEl.style.display = "none"; +} + +function showWidget(w) { + if (!w) return; + w.type = w._origType || "text"; + w.hidden = false; + if (w.options) w.options.hidden = false; + if (w._origComputeSize) { + w.computeSize = w._origComputeSize; + } else { + try { delete w.computeSize; } catch (_) { } + } + if (w.element) w.element.style.display = ""; + if (w.inputEl) w.inputEl.style.display = ""; +} + +function getControlsHeight(node) { + const prev = node.widgets?.find(w => isPreviewWidget(w)); + let h = 0; + if (prev) { + const origLayout = prev.computeLayoutSize; + const origComp = prev.computeSize; + prev.computeLayoutSize = () => ({ minHeight: 0, minWidth: 1 }); + prev.computeSize = () => [0, -4]; + h = node.computeSize()[1]; + if (origLayout) prev.computeLayoutSize = origLayout; + else delete prev.computeLayoutSize; + if (origComp) prev.computeSize = origComp; + else delete prev.computeSize; + } else { + h = node.computeSize()[1]; + } + return h; +} + +function applyVisibility(node) { + if (!node || !node.widgets) return; + + const cls = getNodeClass(node); + const primaryNames = NODE_CONFIG[cls] || []; + const show = !!node._showAdvanced; + + const btn = node.widgets.find(w => w._isAdvBtn); + if (btn) { + const btnIdx = node.widgets.indexOf(btn); + if (btnIdx !== -1) { + node.widgets.splice(btnIdx, 1); + const previewIdx = node.widgets.findIndex(w => isPreviewWidget(w)); + if (previewIdx !== -1) { + node.widgets.splice(previewIdx, 0, btn); + } else { + node.widgets.push(btn); + } + } + } + + for (const w of node.widgets) { + if (w._isAdvBtn || isPreviewWidget(w)) continue; + if (primaryNames.includes(w.name) || show) { + showWidget(w); + } else { + hideWidget(w); + } + } + + const prevWidget = node.widgets.find(w => isPreviewWidget(w)); + if (prevWidget) delete prevWidget.computeSize; + + const minWidth = node.computeSize()[0] || 280; + const currentWidth = (node.size && node.size[0] > 0) ? node.size[0] : 0; + const targetWidth = Math.max(currentWidth, minWidth); + const controlsHeight = getControlsHeight(node); + + let previewHeight = 0; + if (node.imgs && node.imgs.length) { + const img = node.imgs[node.imageIndex || 0] || node.imgs[0]; + if (img) { + if (img.naturalWidth && img.naturalHeight) { + const scale = Math.min(targetWidth / img.naturalWidth, 1); + previewHeight = Math.round(img.naturalHeight * scale) + 15; + } else { + previewHeight = 220; + if (!img._advHooked) { + img._advHooked = true; + const origOnload = img.onload; + img.onload = function () { + if (origOnload) origOnload.apply(this, arguments); + applyVisibility(node); + }; + } + } + } + } + + if (prevWidget && previewHeight > 0) { + prevWidget.computeLayoutSize = () => ({ + minHeight: previewHeight, + prefHeight: previewHeight, + minWidth: 1 + }); + } + + node.setSize([targetWidth, controlsHeight + previewHeight]); + node.setDirtyCanvas(true, true); + app.graph?.setDirtyCanvas(true, true); +} + +function setupNode(node) { + if (node._advToggleInstalled) return; + const cls = getNodeClass(node); + if (!TARGET_NODES.includes(cls)) return; + + if (!node.widgets || node.widgets.length === 0) { + setTimeout(() => setupNode(node), 30); + return; + } + + node._advToggleInstalled = true; + node._showAdvanced = false; + + const imgWidget = node.widgets.find(w => w.name === "image"); + if (imgWidget && !imgWidget._advHooked) { + imgWidget._advHooked = true; + const origCallback = imgWidget.callback; + imgWidget.callback = function () { + const res = origCallback ? origCallback.apply(this, arguments) : undefined; + setTimeout(() => applyVisibility(node), 60); + return res; + }; + } + + const btn = node.addWidget("button", "Advanced", null, () => { + node._showAdvanced = !node._showAdvanced; + applyVisibility(node); + }); + btn._isAdvBtn = true; + btn.serialize = false; + btn.computeSize = () => [0, 26]; + + Object.defineProperty(btn, "width", { + get() { return undefined; }, + set() {} + }); + + btn.draw = function (ctx, n, widgetWidth, y, widgetHeight) { + const margin = 15; + const x = margin; + const w = widgetWidth - margin * 2; + const h = widgetHeight || 24; + const radius = 8; + + const isClicked = !!this.clicked; + this.clicked = false; + + ctx.save(); + ctx.beginPath(); + if (ctx.roundRect) { + ctx.roundRect(x, y, w, h, radius); + } else { + ctx.moveTo(x + radius, y); + ctx.arcTo(x + w, y, x + w, y + h, radius); + ctx.arcTo(x + w, y + h, x, y + h, radius); + ctx.arcTo(x, y + h, x, y, radius); + ctx.arcTo(x, y, x + w, y, radius); + } + ctx.closePath(); + + ctx.fillStyle = isClicked ? "#282828" : "#181818"; + ctx.fill(); + + ctx.strokeStyle = isClicked ? "rgba(255, 255, 255, 0.22)" : "rgba(255, 255, 255, 0.1)"; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.fillStyle = isClicked ? "#ffffff" : "#8e9297"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("Advanced", x + w / 2, y + h / 2); + + ctx.restore(); + + if (isClicked) { + n?.setDirtyCanvas?.(true); + } + }; + + const origOnDrawForeground = node.onDrawForeground; + node.onDrawForeground = function (ctx) { + const b = this.widgets?.find(w => w._isAdvBtn); + if (b) { + const bIdx = this.widgets.indexOf(b); + if (this.widgets.slice(bIdx + 1).some(w => !isPreviewWidget(w))) { + applyVisibility(this); + } + } + return origOnDrawForeground ? origOnDrawForeground.apply(this, arguments) : undefined; + }; + + setTimeout(() => { + applyVisibility(node); + }, 30); +} + +app.registerExtension({ + name: EXTENSION_NAME, + async setup() { + if (app.graph?._nodes) { + for (const node of app.graph._nodes) { + if (TARGET_NODES.includes(getNodeClass(node))) { + setupNode(node); + } + } + } + }, + async nodeCreated(node) { + if (TARGET_NODES.includes(getNodeClass(node))) { + setTimeout(() => setupNode(node), 20); + } + }, + async loadedGraphNode(node) { + if (TARGET_NODES.includes(getNodeClass(node))) { + setTimeout(() => setupNode(node), 20); + } + } +}); diff --git a/web/js/Canvas.js b/web/js/Canvas.js new file mode 100644 index 0000000..6938986 --- /dev/null +++ b/web/js/Canvas.js @@ -0,0 +1,1502 @@ +/** + * Canvas (RMBG) — Frontend for ComfyUI + * + * This integration script is licensed under the GNU General Public License v3.0 (GPL-3.0). + * If you incorporate or modify this code, please credit AILab as the original source: + * https://github.com/1038lab/ComfyUI-RMBG + */ + +import { app } from "/scripts/app.js"; +import { api } from "/scripts/api.js"; + +const STYLE_ID = "ailab-canvas-styles"; +const MIN_DIM = 320; +const MAX_DIM = 4096; +const SIDE_SPACING = 10; +const BOTTOM_SPACING = 10; +const DOM_CHROME_HEIGHT = 146; // 90 (Deck) + 30 (Toolbar) + 12 (Gaps) + 12 (Paddings) + 2 (Borders) + +function snap8(v) { + return Math.round(v / 8) * 8; +} + +function clampDimensions(targetW, ratio) { + let w = snap8(targetW); + let h = snap8(w / ratio); + + // Lower bound: neither width nor height may drop below 320 + if (w < MIN_DIM || h < MIN_DIM) { + if (ratio >= 1.0) { + h = MIN_DIM; + w = snap8(MIN_DIM * ratio); + } else { + w = MIN_DIM; + h = snap8(MIN_DIM / ratio); + } + } + + // Upper bound: neither width nor height may exceed 4096 + if (w > MAX_DIM || h > MAX_DIM) { + if (ratio >= 1.0) { + w = MAX_DIM; + h = snap8(MAX_DIM / ratio); + } else { + h = MAX_DIM; + w = snap8(MAX_DIM * ratio); + } + } + + return { w, h }; +} + +function clampFromHeight(targetH, ratio) { + const h = snap8(targetH); + const w = snap8(h * ratio); + return clampDimensions(w, ratio); +} + +function injectStyles() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + .alc-root { + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; + background: #182230; + border: 1px solid #2d4157; + border-radius: 8px; + padding: 6px; + color: #d7e3ef; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + user-select: none; + gap: 6px; + overflow: hidden; + } + .alc-layer-deck { + flex: 0 0 90px; + height: 90px; + min-height: 90px; + max-height: 90px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 6px; + overflow-x: auto; + overflow-y: hidden; + padding: 4px 6px; + border-radius: 6px; + background: rgba(13, 21, 31, 0.6); + border: 1px solid rgba(255, 255, 255, 0.05); + scrollbar-width: thin; + scrollbar-color: #2a3d52 transparent; + box-sizing: border-box; + } + .alc-layer-deck::-webkit-scrollbar { + height: 4px; + } + .alc-layer-deck::-webkit-scrollbar-thumb { + background: #2a3d52; + border-radius: 2px; + } + .alc-card { + flex: 0 0 80px; + width: 80px; + height: 80px; + min-width: 80px; + max-width: 80px; + min-height: 80px; + max-height: 80px; + border: 1.5px solid #2a3f54; + border-radius: 6px; + background: repeating-conic-gradient(#141d29 0% 25%, #1d2938 0% 50%) 50% / 8px 8px; + position: relative; + cursor: pointer; + overflow: hidden; + transition: border-color 0.15s, box-shadow 0.15s; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + } + .alc-card:hover { + border-color: #08b4ed; + } + .alc-card.alc-active { + border: 2px solid #08b4ed; + box-shadow: 0 0 8px rgba(8, 180, 237, 0.5); + } + .alc-card img { + width: 100%; + height: 100%; + object-fit: contain; + pointer-events: none; + } + .alc-card-badge { + position: absolute; + left: 2px; + top: 2px; + background: rgba(0, 0, 0, 0.75); + color: #ffffff; + font-size: 8px; + font-weight: 600; + padding: 1px 3px; + border-radius: 3px; + pointer-events: none; + z-index: 5; + } + .alc-card-empty { + font-size: 8.5px; + color: #6a8299; + text-align: center; + padding: 2px; + pointer-events: none; + } + .alc-card-remove { + position: absolute; + right: 2px; + top: 2px; + width: 15px; + height: 15px; + border-radius: 50%; + background: rgba(15, 23, 33, 0.85); + color: #ff6b6b; + border: 1px solid rgba(255, 107, 107, 0.6); + font-size: 10px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, background 0.15s, transform 0.1s; + z-index: 10; + padding: 0; + } + .alc-card:hover .alc-card-remove { + opacity: 1; + } + .alc-card-remove:hover { + background: #e63946; + color: #ffffff; + transform: scale(1.15); + } + .alc-toolbar { + flex: 0 0 30px; + height: 30px; + display: flex; + align-items: center; + gap: 6px; + background: #111923; + padding: 2px 6px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 11px; + box-sizing: border-box; + } + .alc-dim-driver { + display: flex; + align-items: center; + gap: 3px; + font-size: 11px; + color: #7b96b2; + font-weight: 500; + } + .alc-dim-input { + width: 44px; + height: 20px; + background: #182230; + border: 1px solid #2d4157; + color: #d7e3ef; + font-size: 11px; + text-align: center; + border-radius: 3px; + padding: 0 2px; + outline: none; + box-sizing: border-box; + } + .alc-dim-input:focus { + border-color: #08b4ed; + } + .alc-btn { + background: #223244; + border: 1px solid #354e66; + color: #e2edf8; + border-radius: 4px; + width: 24px; + height: 24px; + min-width: 24px; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, border-color 0.15s; + padding: 0; + box-sizing: border-box; + } + .alc-btn:hover { + background: #2c425a; + border-color: #08b4ed; + color: #ffffff; + } + .alc-btn.alc-active { + background: #08b4ed; + border-color: #38c8f5; + color: #ffffff; + } + .alc-slider-wrap { + display: flex; + align-items: center; + gap: 5px; + margin-left: auto; + font-size: 11px; + color: #9cb5cb; + white-space: nowrap; + } + .alc-slider-wrap input[type=range] { + width: 60px; + accent-color: #08b4ed; + cursor: pointer; + } + .alc-stage-wrap { + position: relative; + width: 100%; + background: repeating-conic-gradient(#141c26 0% 25%, #192432 0% 50%) 50% / 16px 16px; + border: 1px solid #2d4255; + border-radius: 6px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + } + .alc-canvas { + display: block; + width: 100%; + height: 100%; + cursor: default; + background: transparent; + } + `; + document.head.appendChild(style); +} + +app.registerExtension({ + name: "AILab.Canvas", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "AILab_Canvas") return; + + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + this.hideOutputImages = true; + this.imgs = null; + + if (origOnNodeCreated) { + origOnNodeCreated.apply(this, arguments); + } + + this.hideOutputImages = true; + this.imgs = null; + + injectStyles(); + + const node = this; + + node.canvasLayers = []; + node.activeLayerId = null; + node.bgImage = null; + node.isLocked = false; + node.currentW = 640; + node.currentH = 360; + + const hideWidget = (name) => { + const w = node.widgets?.find(x => x.name === name); + if (w) { + w.type = "hidden"; + w.computeSize = () => [0, -4]; + w.draw = () => { }; + } + return w; + }; + + const wCanvasWidth = hideWidget("canvas_width"); + const wCanvasHeight = hideWidget("canvas_height"); + let wCanvasState = hideWidget("canvas_state"); + if (!wCanvasState) { + wCanvasState = node.widgets?.find(x => x.name === "canvas_state"); + } + if (!wCanvasState && node.addWidget) { + wCanvasState = node.addWidget("text", "canvas_state", "{}", () => { }, { multiline: true }); + wCanvasState.type = "hidden"; + wCanvasState.computeSize = () => [0, -4]; + wCanvasState.draw = () => { }; + } + const wBg = node.widgets?.find(x => x.name === "background"); + const wBgColor = node.widgets?.find(x => x.name === "background_color"); + const wFitBg = hideWidget("fit_background_size"); + + if (wBg) { + const oldBgCb = wBg.callback; + wBg.callback = function () { + if (oldBgCb) oldBgCb.apply(this, arguments); + node.renderStage(); + }; + } + if (wBgColor) { + const oldColorCb = wBgColor.callback; + wBgColor.callback = function () { + if (oldColorCb) oldColorCb.apply(this, arguments); + node.renderStage(); + }; + } + if (wFitBg) { + const oldFitCb = wFitBg.callback; + wFitBg.callback = function () { + if (oldFitCb) oldFitCb.apply(this, arguments); + if (wFitBg.value && node.bgImage) { + node.fitToBackground(); + } + }; + } + + const root = document.createElement("div"); + root.className = "alc-root"; + + // Tier 1: Layer Preview Deck (Top of DOM Panel) + const previewDeck = document.createElement("div"); + previewDeck.className = "alc-layer-deck"; + + // Tier 2: Control Toolbar (Middle of DOM Panel) + const toolbar = document.createElement("div"); + toolbar.className = "alc-toolbar"; + + const dimDriver = document.createElement("div"); + dimDriver.className = "alc-dim-driver"; + + const wLabel = document.createElement("span"); + wLabel.textContent = "W:"; + const wInput = document.createElement("input"); + wInput.type = "number"; + wInput.step = "8"; + wInput.className = "alc-dim-input alc-w-input"; + wInput.value = 640; + + const xLabel = document.createElement("span"); + xLabel.textContent = "×"; + + const hLabel = document.createElement("span"); + hLabel.textContent = "H:"; + const hInput = document.createElement("input"); + hInput.type = "number"; + hInput.step = "8"; + hInput.className = "alc-dim-input alc-h-input"; + hInput.value = 360; + + dimDriver.appendChild(wLabel); + dimDriver.appendChild(wInput); + dimDriver.appendChild(xLabel); + dimDriver.appendChild(hLabel); + dimDriver.appendChild(hInput); + + const btnSep = document.createElement("div"); + btnSep.style.cssText = "width: 1px; height: 16px; background: rgba(255, 255, 255, 0.1); margin: 0 2px;"; + + const createBtn = (icon, title, onClick) => { + const b = document.createElement("button"); + b.className = "alc-btn"; + b.textContent = icon; + b.title = title; + b.addEventListener("click", (e) => { + e.stopPropagation(); + onClick(b); + }); + return b; + }; + + const btnFlipH = createBtn("⇄", "Flip Horizontal", () => node.flipActiveLayer(true, false)); + const btnFlipV = createBtn("⇅", "Flip Vertical", () => node.flipActiveLayer(false, true)); + const btnReset = createBtn("↺", "Reset Transform", () => node.resetActiveLayer()); + const btnFit = createBtn("⤢", "Fit Background Size", () => { + if (wFitBg) wFitBg.value = true; + node.fitToBackground(); + }); + const btnLock = createBtn("🔓", "Lock Layout", (btn) => { + node.isLocked = !node.isLocked; + btn.textContent = node.isLocked ? "🔒" : "🔓"; + btn.classList.toggle("alc-active", node.isLocked); + node.saveCanvasState(); + node.renderStage(); + }); + + const sliderWrap = document.createElement("div"); + sliderWrap.className = "alc-slider-wrap"; + const opLabel = document.createElement("span"); + opLabel.textContent = "Opacity:"; + const opSlider = document.createElement("input"); + opSlider.type = "range"; + opSlider.min = "0"; + opSlider.max = "100"; + opSlider.value = "100"; + const opVal = document.createElement("span"); + opVal.style.cssText = "min-width: 32px; text-align: right; color: #9cb5cb;"; + opVal.textContent = "100%"; + + opSlider.addEventListener("input", (e) => { + if (node.isLocked) return; + const v = parseInt(e.target.value, 10); + opVal.textContent = v + "%"; + node.setActiveLayerOpacity(v / 100.0); + }); + + sliderWrap.appendChild(opLabel); + sliderWrap.appendChild(opSlider); + sliderWrap.appendChild(opVal); + + toolbar.appendChild(dimDriver); + toolbar.appendChild(btnSep); + toolbar.appendChild(btnFlipH); + toolbar.appendChild(btnFlipV); + toolbar.appendChild(btnReset); + toolbar.appendChild(btnFit); + toolbar.appendChild(btnLock); + toolbar.appendChild(sliderWrap); + + // Tier 3: Stage Area (Bottom of DOM Panel) + const stageWrap = document.createElement("div"); + stageWrap.className = "alc-stage-wrap"; + const stageCanvas = document.createElement("canvas"); + stageCanvas.className = "alc-canvas"; + stageCanvas.width = 640; + stageCanvas.height = 360; + stageWrap.appendChild(stageCanvas); + + // Strict Vertical Stacking: Deck (Top) -> Toolbar (Middle) -> Stage (Bottom) + root.appendChild(previewDeck); + root.appendChild(toolbar); + root.appendChild(stageWrap); + + const domWidget = node.addDOMWidget("AILab_Canvas_UI", "custom_ui", root, { + serialize: false, + hideOnZoom: false, + }); + + const origWidgetDraw = domWidget.draw; + domWidget.draw = function (ctx, n, widget_width, y, widget_height) { + if (typeof y === "number" && y > 30) { + domWidget.last_y = y; + const stageH = stageCanvas.height || 360; + const domH = DOM_CHROME_HEIGHT + stageH; + const expectedH = Math.ceil(y + domH + BOTTOM_SPACING); + if (node.size && Math.abs(node.size[1] - expectedH) > 2) { + node.size[1] = expectedH; + } + } + return origWidgetDraw ? origWidgetDraw.apply(this, arguments) : undefined; + }; + + node.getNativeHeaderHeight = function () { + if (domWidget && typeof domWidget.last_y === "number" && domWidget.last_y > 30) { + return domWidget.last_y; + } + const numInputs = node.inputs ? node.inputs.length : 1; + const numOutputs = node.outputs ? node.outputs.length : 1; + const slotsH = Math.max(numInputs, numOutputs) * 20; + const startY = Math.max(30, slotsH + 10); + const regularWidgets = (node.widgets || []).filter(w => w !== domWidget && w.type !== "hidden").length; + return startY + regularWidgets * 24 + 4; + }; + + node.getCurrentAspectRatio = function () { + if (node.bgImage && node.bgImage.naturalWidth && node.bgImage.naturalHeight) { + return node.bgImage.naturalWidth / node.bgImage.naturalHeight; + } + const w = node.currentW || 640; + const h = node.currentH || 360; + return Math.max(0.01, w / Math.max(1, h)); + }; + + // Single Source of Truth for all node dimensions & aspect ratio scaling + node.setCanvasDimensions = function (targetW, targetH) { + const w = Math.max(MIN_DIM, Math.min(MAX_DIM, snap8(targetW))); + const h = Math.max(MIN_DIM, Math.min(MAX_DIM, snap8(targetH))); + + const oldW = node.currentW; + const oldH = node.currentH; + + node.currentW = w; + node.currentH = h; + wInput.value = w; + hInput.value = h; + const cw = wCanvasWidth || node.widgets?.find(x => x.name === "canvas_width"); + if (cw) cw.value = w; + const ch = wCanvasHeight || node.widgets?.find(x => x.name === "canvas_height"); + if (ch) ch.value = h; + + // Reposition existing layers relative to canvas, but NEVER distort layer aspect ratio + if (oldW && oldH && (oldW !== w || oldH !== h)) { + const factorX = w / oldW; + const factorY = h / oldH; + const isUniformCanvasScale = Math.abs(factorX - factorY) < 0.05; + const uniformScaleFactor = isUniformCanvasScale ? factorX : 1.0; + for (const l of node.canvasLayers) { + if (typeof l.x === "number") l.x = Math.round(l.x * factorX); + if (typeof l.y === "number") l.y = Math.round(l.y * factorY); + // Layer aspect ratio is locked: scaleX and scaleY are strictly equal + const curScale = (typeof l.scaleX === "number") ? l.scaleX : (l.scaleY ?? 1.0); + const newScale = Number((curScale * uniformScaleFactor).toFixed(4)); + l.scaleX = newScale; + l.scaleY = newScale; + } + } + + stageCanvas.width = w; + stageCanvas.height = h; + stageWrap.style.height = h + "px"; + + const nodeW = w + SIDE_SPACING * 2; + const nodeH = node.getNativeHeaderHeight() + DOM_CHROME_HEIGHT + h + BOTTOM_SPACING; + node.size = [nodeW, nodeH]; + + node.saveCanvasState(); + node.renderStage(); + node.setDirtyCanvas?.(true, true); + }; + + node.syncGeometry = function (targetCanvasWidth) { + if (node.bgImage && node.bgImage.naturalWidth && node.bgImage.naturalHeight) { + const ratio = node.bgImage.naturalWidth / node.bgImage.naturalHeight; + const { w, h } = clampDimensions(targetCanvasWidth, ratio); + node.setCanvasDimensions(w, h); + } else { + const w = Math.max(MIN_DIM, Math.min(MAX_DIM, snap8(targetCanvasWidth))); + const h = node.currentH || 360; + node.setCanvasDimensions(w, h); + } + }; + + node.syncGeometryFromHeight = function (targetCanvasHeight) { + if (node.bgImage && node.bgImage.naturalWidth && node.bgImage.naturalHeight) { + const ratio = node.bgImage.naturalWidth / node.bgImage.naturalHeight; + const { w, h } = clampFromHeight(targetCanvasHeight, ratio); + node.setCanvasDimensions(w, h); + } else { + const w = node.currentW || 640; + const h = Math.max(MIN_DIM, Math.min(MAX_DIM, snap8(targetCanvasHeight))); + node.setCanvasDimensions(w, h); + } + }; + + // LiteGraph min size definition + node.computeSize = function (out) { + if (node.bgImage && node.bgImage.naturalWidth && node.bgImage.naturalHeight) { + const ratio = node.bgImage.naturalWidth / node.bgImage.naturalHeight; + const minDims = clampDimensions(0, ratio); + const minNodeW = minDims.w + SIDE_SPACING * 2; + const minNodeH = node.getNativeHeaderHeight() + DOM_CHROME_HEIGHT + minDims.h + BOTTOM_SPACING; + if (out) { + out[0] = minNodeW; + out[1] = minNodeH; + return out; + } + return [minNodeW, minNodeH]; + } + const minNodeW = MIN_DIM + SIDE_SPACING * 2; + const minNodeH = node.getNativeHeaderHeight() + DOM_CHROME_HEIGHT + MIN_DIM + BOTTOM_SPACING; + if (out) { + out[0] = minNodeW; + out[1] = minNodeH; + return out; + } + return [minNodeW, minNodeH]; + }; + + domWidget.computeSize = function (width) { + if (node.bgImage && node.bgImage.naturalWidth && node.bgImage.naturalHeight) { + const ratio = node.bgImage.naturalWidth / node.bgImage.naturalHeight; + const targetW = width ? Math.max(MIN_DIM, width - SIDE_SPACING * 2) : (node.currentW || 640); + const { w, h } = clampDimensions(targetW, ratio); + return [w + SIDE_SPACING * 2, DOM_CHROME_HEIGHT + h]; + } + const w = width ? Math.max(MIN_DIM, width - SIDE_SPACING * 2) : (node.currentW || 640); + const h = node.currentH || 360; + return [w + SIDE_SPACING * 2, DOM_CHROME_HEIGHT + h]; + }; + + node.onResize = function (newSize) { + if (wFitBg) wFitBg.value = false; + if (node.bgImage && node.bgImage.naturalWidth && node.bgImage.naturalHeight) { + const targetW = newSize[0] - SIDE_SPACING * 2; + node.syncGeometry(targetW); + } else { + const targetW = newSize[0] - SIDE_SPACING * 2; + const headerH = node.getNativeHeaderHeight(); + const targetH = newSize[1] - headerH - DOM_CHROME_HEIGHT - BOTTOM_SPACING; + node.setCanvasDimensions(targetW, targetH); + } + newSize[0] = node.size[0]; + newSize[1] = node.size[1]; + }; + + wInput.addEventListener("change", () => { + if (wFitBg) wFitBg.value = false; + const val = parseInt(wInput.value, 10) || 640; + node.syncGeometry(val); + }); + + hInput.addEventListener("change", () => { + if (wFitBg) wFitBg.value = false; + const val = parseInt(hInput.value, 10) || 360; + node.syncGeometryFromHeight(val); + }); + + // Enforce that any newly loaded layer does not exceed 85% of stage width/height + node.applyInitialLayerConstraints = function (layer, img) { + const stageW = node.currentW || 640; + const stageH = node.currentH || 360; + const maxW = stageW * 0.85; + const maxH = stageH * 0.85; + const imgW = img.naturalWidth || 1; + const imgH = img.naturalHeight || 1; + + if (imgW > maxW || imgH > maxH) { + const fitScale = Math.min(maxW / imgW, maxH / imgH); + layer.scaleX = Number(fitScale.toFixed(4)); + layer.scaleY = Number(fitScale.toFixed(4)); + } else { + layer.scaleX = 1.0; + layer.scaleY = 1.0; + } + layer.x = Math.round(stageW / 2); + layer.y = Math.round(stageH / 2); + layer.angle = 0; + layer.flipX = false; + layer.flipY = false; + layer.userModified = false; + }; + + node.renderPreviewDeck = function () { + previewDeck.innerHTML = ""; + const totalCards = [ + { id: "Background", label: "BG", isBg: true, image: node.bgImage } + ]; + + if (node.inputs) { + for (const inp of node.inputs) { + if (inp.name.startsWith("layer_")) { + const lNum = inp.name.replace("layer_", ""); + const l = node.canvasLayers.find(x => x.id === inp.name); + const hasLink = inp.link != null && (!app.graph || !app.graph.links || app.graph.links[inp.link] != null); + const hasImage = Boolean(l && l.image && l.image.src); + + // Layer 1 is base layer: show if connected, has image, or empty initial state. + // Dynamic layers (layer_2+) MUST ONLY be shown if actively connected or having an image. + const shouldShow = (inp.name === "layer_1") + ? (hasLink || hasImage || (!node.canvasLayers.length && !node.inputs.some(i => i.name.startsWith("layer_") && i.name !== "layer_1" && (i.link != null && (!app.graph?.links || app.graph.links[i.link]))))) + : (hasLink || hasImage); + + if (shouldShow) { + totalCards.push({ + id: inp.name, + label: `L${lNum}`, + isBg: false, + layer: l, + image: (l && l.image && l.image.src) ? l.image : null + }); + } + } + } + } + + totalCards.forEach((c) => { + const card = document.createElement("div"); + card.className = "alc-card"; + if (node.activeLayerId === c.id) { + card.classList.add("alc-active"); + } + + const badge = document.createElement("div"); + badge.className = "alc-card-badge"; + badge.textContent = c.label; + card.appendChild(badge); + + if (c.image && c.image.src) { + const img = document.createElement("img"); + img.src = c.image.src; + card.appendChild(img); + } else { + const empty = document.createElement("div"); + empty.className = "alc-card-empty"; + empty.textContent = c.isBg ? "No BG" : "No Img"; + card.appendChild(empty); + } + + if (c.isBg && node.bgImage) { + const closeBtn = document.createElement("div"); + closeBtn.className = "alc-card-remove"; + closeBtn.textContent = "✕"; + closeBtn.title = "Delete Background Image"; + closeBtn.addEventListener("pointerdown", (e) => e.stopPropagation()); + closeBtn.addEventListener("mousedown", (e) => e.stopPropagation()); + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + node.disconnectBgInput(); + }); + card.appendChild(closeBtn); + } else if (!c.isBg && (c.image || c.layer)) { + const closeBtn = document.createElement("div"); + closeBtn.className = "alc-card-remove"; + closeBtn.textContent = "✕"; + closeBtn.title = `Delete ${c.label}`; + closeBtn.addEventListener("pointerdown", (e) => e.stopPropagation()); + closeBtn.addEventListener("mousedown", (e) => e.stopPropagation()); + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + node.disconnectLayerInput(c.id); + }); + card.appendChild(closeBtn); + } + + card.addEventListener("click", () => { + node.activeLayerId = c.id; + if (c.layer) { + const pct = Math.round((c.layer.opacity ?? 1.0) * 100); + opSlider.value = pct; + opVal.textContent = pct + "%"; + } + node.renderPreviewDeck(); + node.renderStage(); + }); + + previewDeck.appendChild(card); + }); + }; + + node.disconnectBgInput = function () { + const slotIndex = node.findInputSlot("Background"); + if (slotIndex >= 0 && node.inputs && node.inputs[slotIndex] && node.inputs[slotIndex].link != null) { + node.disconnectInput(slotIndex); + } + node.bgImage = null; + node.setCanvasDimensions(node.currentW || 640, node.currentH || 360); + node.saveCanvasState(); + node.renderPreviewDeck(); + node.renderStage(); + }; + + node.disconnectLayerInput = function (layerId) { + const slotIndex = node.findInputSlot(layerId); + if (slotIndex >= 0 && node.inputs && node.inputs[slotIndex] && node.inputs[slotIndex].link != null) { + node.disconnectInput(slotIndex); + } + node.canvasLayers = (node.canvasLayers || []).filter(l => l.id !== layerId); + if (node.activeLayerId === layerId) { + node.activeLayerId = null; + } + node.manageDynamicSlots(); + node.saveCanvasState(); + node.renderPreviewDeck(); + node.renderStage(); + }; + + node.renderStage = function () { + const ctx = stageCanvas.getContext("2d"); + const W = stageCanvas.width; + const H = stageCanvas.height; + ctx.clearRect(0, 0, W, H); + + const bgType = wBg ? wBg.value : "Alpha"; + const bgColor = wBgColor ? wBgColor.value : "#222222"; + + if (node.bgImage && node.bgImage.complete && node.bgImage.naturalWidth > 0) { + ctx.drawImage(node.bgImage, 0, 0, W, H); + } else if (bgType === "Color") { + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, W, H); + } else { + ctx.save(); + const patSize = 16; + for (let y = 0; y < H; y += patSize) { + for (let x = 0; x < W; x += patSize) { + ctx.fillStyle = ((x / patSize + y / patSize) % 2 === 0) ? "#141c26" : "#192432"; + ctx.fillRect(x, y, patSize, patSize); + } + } + ctx.restore(); + } + + const sortedLayers = [...node.canvasLayers].sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0)); + const scaleRatioX = W / (node.currentW || W); + const scaleRatioY = H / (node.currentH || H); + + sortedLayers.forEach(l => { + if (!l.image || !l.image.complete) return; + ctx.save(); + const lx = (l.x ?? (node.currentW / 2)) * scaleRatioX; + const ly = (l.y ?? (node.currentH / 2)) * scaleRatioY; + ctx.translate(lx, ly); + ctx.rotate(((l.angle || 0) * Math.PI) / 180); + ctx.scale(l.flipX ? -1 : 1, l.flipY ? -1 : 1); + ctx.globalAlpha = Math.max(0, Math.min(1, l.opacity ?? 1.0)); + + // Strictly lock layer scale to natural aspect ratio + const scale = (typeof l.scaleX === "number") ? l.scaleX : (l.scaleY ?? 1.0); + l.scaleX = scale; + l.scaleY = scale; + const lw = (l.image.naturalWidth * scale) * scaleRatioX; + const lh = (l.image.naturalHeight * scale) * scaleRatioY; + ctx.drawImage(l.image, -lw / 2, -lh / 2, lw, lh); + ctx.restore(); + + if (!node.isLocked && node.activeLayerId === l.id) { + node.drawGizmo(ctx, lx, ly, lw, lh, l.angle || 0); + } + }); + }; + + node.drawGizmo = function (ctx, cx, cy, w, h, angle) { + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate((angle * Math.PI) / 180); + + ctx.strokeStyle = "#08b4ed"; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 4]); + ctx.strokeRect(-w / 2, -h / 2, w, h); + ctx.setLineDash([]); + + const handles = [ + [-w / 2, -h / 2], [w / 2, -h / 2], + [-w / 2, h / 2], [w / 2, h / 2], + ]; + + ctx.fillStyle = "#ffffff"; + ctx.strokeStyle = "#08b4ed"; + ctx.lineWidth = 1.5; + const hs = 7; + handles.forEach(([hx, hy]) => { + ctx.fillRect(hx - hs / 2, hy - hs / 2, hs, hs); + ctx.strokeRect(hx - hs / 2, hy - hs / 2, hs, hs); + }); + + ctx.beginPath(); + ctx.moveTo(0, -h / 2); + ctx.lineTo(0, -h / 2 - 18); + ctx.strokeStyle = "#08b4ed"; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(0, -h / 2 - 18, 4.5, 0, Math.PI * 2); + ctx.fillStyle = "#08b4ed"; + ctx.fill(); + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1.2; + ctx.stroke(); + + ctx.restore(); + }; + + node.flipActiveLayer = function (h, v) { + if (node.isLocked || !node.activeLayerId) return; + const l = node.canvasLayers.find(x => x.id === node.activeLayerId); + if (!l) return; + if (h) l.flipX = !l.flipX; + if (v) l.flipY = !l.flipY; + l.userModified = true; + node.saveCanvasState(); + node.renderStage(); + }; + + node.resetActiveLayer = function () { + if (node.isLocked || !node.activeLayerId) return; + const l = node.canvasLayers.find(x => x.id === node.activeLayerId); + if (!l) return; + if (l.image && l.image.complete) { + node.applyInitialLayerConstraints(l, l.image); + } else { + l.x = (node.currentW || 640) / 2; + l.y = (node.currentH || 360) / 2; + l.scaleX = 1.0; + l.scaleY = 1.0; + l.angle = 0; + l.flipX = false; + l.flipY = false; + } + l.opacity = 1.0; + opSlider.value = 100; + opVal.textContent = "100%"; + node.saveCanvasState(); + node.renderStage(); + }; + + node.fitToBackground = function () { + if (!node.bgImage || !node.bgImage.naturalWidth || !node.bgImage.naturalHeight) return; + if (wFitBg) wFitBg.value = true; + node.setCanvasDimensions(node.bgImage.naturalWidth, node.bgImage.naturalHeight); + }; + + node.onBackgroundLoaded = function (img) { + node.bgImage = img; + const bgW = img.naturalWidth; + const bgH = img.naturalHeight; + if (!bgW || !bgH) return; + + const ratio = bgW / bgH; + const curW = node.currentW || 640; + const curH = node.currentH || 360; + const curRatio = curW / Math.max(1, curH); + const isRatioMatched = Math.abs(curRatio - ratio) / ratio < 0.02; + + if (wFitBg && wFitBg.value) { + node.setCanvasDimensions(bgW, bgH); + } else if (!isRatioMatched) { + const { w, h } = clampDimensions(curW, ratio); + node.setCanvasDimensions(w, h); + } else { + node.setCanvasDimensions(curW, curH); + } + + for (const l of node.canvasLayers) { + if (l.image && l.userModified !== true) { + node.applyInitialLayerConstraints(l, l.image); + } + } + + node.saveCanvasState(); + node.renderPreviewDeck(); + node.renderStage(); + }; + + node.onLayerImageLoaded = function (slotName, img) { + let layer = node.canvasLayers.find(l => l.id === slotName); + const isNew = !layer; + if (isNew) { + layer = { + id: slotName, + opacity: 1.0, + zIndex: parseInt(slotName.replace("layer_", ""), 10) || 1, + }; + node.canvasLayers.push(layer); + } + layer.image = img; + if (isNew || layer.userModified !== true) { + node.applyInitialLayerConstraints(layer, img); + } + node.saveCanvasState(); + node.renderPreviewDeck(); + node.renderStage(); + }; + + node.setActiveLayerOpacity = function (opacity) { + if (node.isLocked || !node.activeLayerId) return; + const l = node.canvasLayers.find(x => x.id === node.activeLayerId); + if (!l) return; + l.opacity = opacity; + node.saveCanvasState(); + node.renderStage(); + }; + + node.saveCanvasState = function () { + const state = { + canvas_width: node.currentW || 640, + canvas_height: node.currentH || 360, + locked: !!node.isLocked, + layers: node.canvasLayers.map(l => ({ + id: l.id, + x: l.x ?? ((node.currentW || 640) / 2), + y: l.y ?? ((node.currentH || 360) / 2), + scaleX: l.scaleX ?? 1.0, + scaleY: l.scaleY ?? 1.0, + angle: l.angle ?? 0.0, + flipX: !!l.flipX, + flipY: !!l.flipY, + opacity: l.opacity ?? 1.0, + zIndex: l.zIndex ?? 1 + })) + }; + let w = wCanvasState || node.widgets?.find(x => x.name === "canvas_state"); + if (!w && node.addWidget) { + w = node.addWidget("text", "canvas_state", "{}", () => { }, { multiline: true }); + w.type = "hidden"; + w.computeSize = () => [0, -4]; + w.draw = () => { }; + } + if (w) { + w.value = JSON.stringify(state); + } + }; + + function getStageCoords(e) { + const rect = stageCanvas.getBoundingClientRect(); + const sx = stageCanvas.width / Math.max(1, rect.width); + const sy = stageCanvas.height / Math.max(1, rect.height); + return { + x: (e.clientX - rect.left) * sx, + y: (e.clientY - rect.top) * sy + }; + } + + let dragMode = null; + let dragStartPos = null; + let activeLayerInitial = null; + + stageCanvas.addEventListener("pointerdown", (e) => { + if (node.isLocked) return; + const pos = getStageCoords(e); + const W = stageCanvas.width; + const H = stageCanvas.height; + const scaleRatioX = W / (node.currentW || W); + const scaleRatioY = H / (node.currentH || H); + + let hitLayer = null; + dragMode = null; + + // Test handles on currently active layer first + if (node.activeLayerId) { + const l = node.canvasLayers.find(x => x.id === node.activeLayerId); + if (l && l.image && l.image.complete) { + const lx = (l.x ?? (node.currentW / 2)) * scaleRatioX; + const ly = (l.y ?? (node.currentH / 2)) * scaleRatioY; + const lw = (l.image.naturalWidth * (l.scaleX || 1.0)) * scaleRatioX; + const lh = (l.image.naturalHeight * (l.scaleY || 1.0)) * scaleRatioY; + + const rad = -((l.angle || 0) * Math.PI) / 180; + const dx = pos.x - lx; + const dy = pos.y - ly; + const rx = dx * Math.cos(rad) - dy * Math.sin(rad); + const ry = dx * Math.sin(rad) + dy * Math.cos(rad); + + // 1. Rotation handle + const rotDist = Math.hypot(rx, ry - (-lh / 2 - 18)); + if (rotDist <= 12) { + hitLayer = l; + dragMode = "rotate"; + } else { + // 2. Four corner scale handles + const scaleHandles = [ + { id: "scale_tl", x: -lw / 2, y: -lh / 2 }, + { id: "scale_tr", x: lw / 2, y: -lh / 2 }, + { id: "scale_bl", x: -lw / 2, y: lh / 2 }, + { id: "scale_br", x: lw / 2, y: lh / 2 }, + ]; + const tol = 9; + for (const h of scaleHandles) { + if (Math.abs(rx - h.x) <= tol && Math.abs(ry - h.y) <= tol) { + hitLayer = l; + dragMode = h.id; + break; + } + } + } + } + } + + // If no handle was clicked, test body click across all layers (topmost first) + if (!hitLayer) { + const sorted = [...node.canvasLayers].sort((a, b) => (b.zIndex || 0) - (a.zIndex || 0)); + for (const l of sorted) { + if (!l.image || !l.image.complete) continue; + const lx = (l.x ?? (node.currentW / 2)) * scaleRatioX; + const ly = (l.y ?? (node.currentH / 2)) * scaleRatioY; + const lw = (l.image.naturalWidth * (l.scaleX || 1.0)) * scaleRatioX; + const lh = (l.image.naturalHeight * (l.scaleY || 1.0)) * scaleRatioY; + + const rad = -((l.angle || 0) * Math.PI) / 180; + const dx = pos.x - lx; + const dy = pos.y - ly; + const rx = dx * Math.cos(rad) - dy * Math.sin(rad); + const ry = dx * Math.sin(rad) + dy * Math.cos(rad); + + if (Math.abs(rx) <= lw / 2 && Math.abs(ry) <= lh / 2) { + hitLayer = l; + dragMode = "move"; + break; + } + } + } + + if (hitLayer) { + node.activeLayerId = hitLayer.id; + const pct = Math.round((hitLayer.opacity ?? 1.0) * 100); + opSlider.value = pct; + opVal.textContent = pct + "%"; + dragStartPos = pos; + activeLayerInitial = { + x: hitLayer.x ?? (node.currentW / 2), + y: hitLayer.y ?? (node.currentH / 2), + scaleX: hitLayer.scaleX ?? 1.0, + scaleY: hitLayer.scaleY ?? 1.0, + angle: hitLayer.angle ?? 0.0, + natW: hitLayer.image.naturalWidth, + natH: hitLayer.image.naturalHeight, + }; + stageCanvas.setPointerCapture(e.pointerId); + node.renderPreviewDeck(); + node.renderStage(); + } else { + node.activeLayerId = null; + dragMode = null; + node.renderPreviewDeck(); + node.renderStage(); + } + }); + + stageCanvas.addEventListener("pointermove", (e) => { + const pos = getStageCoords(e); + const W = stageCanvas.width; + const H = stageCanvas.height; + const scaleRatioX = W / (node.currentW || W); + const scaleRatioY = H / (node.currentH || H); + + // Hover cursor feedback when not dragging + if (!dragMode) { + if (node.isLocked || !node.activeLayerId) { + stageCanvas.style.cursor = "default"; + return; + } + const l = node.canvasLayers.find(x => x.id === node.activeLayerId); + if (!l || !l.image || !l.image.complete) { + stageCanvas.style.cursor = "default"; + return; + } + const lx = (l.x ?? (node.currentW / 2)) * scaleRatioX; + const ly = (l.y ?? (node.currentH / 2)) * scaleRatioY; + const lw = (l.image.naturalWidth * (l.scaleX || 1.0)) * scaleRatioX; + const lh = (l.image.naturalHeight * (l.scaleY || 1.0)) * scaleRatioY; + + const rad = -((l.angle || 0) * Math.PI) / 180; + const dx = pos.x - lx; + const dy = pos.y - ly; + const rx = dx * Math.cos(rad) - dy * Math.sin(rad); + const ry = dx * Math.sin(rad) + dy * Math.cos(rad); + + const rotDist = Math.hypot(rx, ry - (-lh / 2 - 18)); + if (rotDist <= 12) { + stageCanvas.style.cursor = "crosshair"; + return; + } + + const handles = [ + { id: "scale_tl", x: -lw / 2, y: -lh / 2, cursor: "nwse-resize" }, + { id: "scale_tr", x: lw / 2, y: -lh / 2, cursor: "nesw-resize" }, + { id: "scale_bl", x: -lw / 2, y: lh / 2, cursor: "nesw-resize" }, + { id: "scale_br", x: lw / 2, y: lh / 2, cursor: "nwse-resize" }, + ]; + const tol = 9; + const matched = handles.find(h => Math.abs(rx - h.x) <= tol && Math.abs(ry - h.y) <= tol); + if (matched) { + stageCanvas.style.cursor = matched.cursor; + return; + } + if (Math.abs(rx) <= lw / 2 && Math.abs(ry) <= lh / 2) { + stageCanvas.style.cursor = "move"; + return; + } + stageCanvas.style.cursor = "default"; + return; + } + + if (node.isLocked || !node.activeLayerId) return; + const l = node.canvasLayers.find(x => x.id === node.activeLayerId); + if (!l || !activeLayerInitial) return; + + l.userModified = true; + + if (dragMode === "move") { + stageCanvas.style.cursor = "move"; + const dx = (pos.x - dragStartPos.x) / scaleRatioX; + const dy = (pos.y - dragStartPos.y) / scaleRatioY; + l.x = activeLayerInitial.x + dx; + l.y = activeLayerInitial.y + dy; + node.renderStage(); + } else if (dragMode === "rotate") { + stageCanvas.style.cursor = "crosshair"; + const lx = (l.x ?? (node.currentW / 2)) * scaleRatioX; + const ly = (l.y ?? (node.currentH / 2)) * scaleRatioY; + let angle = Math.atan2(pos.y - ly, pos.x - lx) * (180 / Math.PI) + 90; + if (e.shiftKey) { + angle = Math.round(angle / 15) * 15; + } + l.angle = angle; + node.renderStage(); + } else if (dragMode.startsWith("scale_")) { + const initCx = activeLayerInitial.x * scaleRatioX; + const initCy = activeLayerInitial.y * scaleRatioY; + const rad = ((activeLayerInitial.angle || 0) * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const invCos = Math.cos(-rad); + const invSin = Math.sin(-rad); + + const dx = pos.x - initCx; + const dy = pos.y - initCy; + const localX = dx * invCos - dy * invSin; + const localY = dx * invSin + dy * invCos; + + const W0 = activeLayerInitial.natW * activeLayerInitial.scaleX * scaleRatioX; + const H0 = activeLayerInitial.natH * activeLayerInitial.scaleY * scaleRatioY; + const hw = W0 / 2; + const hh = H0 / 2; + + const anchors = { + "scale_br": { ax: -hw, ay: -hh, dirX: 1, dirY: 1, cursor: "nwse-resize" }, + "scale_tl": { ax: hw, ay: hh, dirX: -1, dirY: -1, cursor: "nwse-resize" }, + "scale_tr": { ax: -hw, ay: hh, dirX: 1, dirY: -1, cursor: "nesw-resize" }, + "scale_bl": { ax: hw, ay: -hh, dirX: -1, dirY: 1, cursor: "nesw-resize" }, + }; + + const h = anchors[dragMode]; + if (h) { + stageCanvas.style.cursor = h.cursor; + const vx = (localX - h.ax) * h.dirX; + const vy = (localY - h.ay) * h.dirY; + + // Proportional scaling maintaining aspect ratio + const diag = Math.hypot(W0, H0); + const proj = (vx * W0 + vy * H0) / Math.max(1, diag); + const scaleFactor = Math.max(0.05, proj / Math.max(1, diag)); + const newW = Math.max(16, W0 * scaleFactor); + const newH = Math.max(16, H0 * scaleFactor); + + const newLocalCx = h.ax + (newW / 2) * h.dirX; + const newLocalCy = h.ay + (newH / 2) * h.dirY; + + const newStageCx = initCx + newLocalCx * cos - newLocalCy * sin; + const newStageCy = initCy + newLocalCx * sin + newLocalCy * cos; + + l.x = newStageCx / scaleRatioX; + l.y = newStageCy / scaleRatioY; + const s = Number((newW / Math.max(1, activeLayerInitial.natW * scaleRatioX)).toFixed(4)); + l.scaleX = s; + l.scaleY = s; + node.renderStage(); + } + } + }); + + stageCanvas.addEventListener("pointerup", (e) => { + if (dragMode) { + dragMode = null; + stageCanvas.style.cursor = "default"; + stageCanvas.releasePointerCapture(e.pointerId); + node.saveCanvasState(); + } + }); + + // Initial geometry setup at standard 640px canvas + node.syncGeometry(640); + node.renderPreviewDeck(); + }; + + // Workflow reload / browser refresh handler: restores layout without void gap + const origOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const res = origOnConfigure?.apply(this, arguments); + const wCanvasWidth = this.widgets?.find(x => x.name === "canvas_width"); + const savedW = (wCanvasWidth && wCanvasWidth.value > 0) + ? wCanvasWidth.value + : ((this.size && this.size[0] > 0) ? this.size[0] - SIDE_SPACING * 2 : 640); + this.syncGeometry(savedW); + + const wCanvasState = this.widgets?.find(x => x.name === "canvas_state"); + if (wCanvasState && wCanvasState.value) { + try { + const parsed = JSON.parse(wCanvasState.value); + if (parsed && Array.isArray(parsed.layers)) { + for (const sl of parsed.layers) { + let layer = this.canvasLayers.find(l => l.id === sl.id); + if (!layer) { + layer = { id: sl.id }; + this.canvasLayers.push(layer); + } + layer.x = sl.x; + layer.y = sl.y; + // Enforce uniform scale to un-distort any previously deformed layer states + const unifScale = (typeof sl.scaleX === "number") ? sl.scaleX : (sl.scaleY ?? 1.0); + layer.scaleX = unifScale; + layer.scaleY = unifScale; + layer.angle = sl.angle; + layer.flipX = sl.flipX; + layer.flipY = sl.flipY; + layer.opacity = sl.opacity; + layer.zIndex = sl.zIndex; + layer.userModified = true; + } + } + } catch (e) { } + } + return res; + }; + + // Serialization hook: ensure latest layout is saved to canvas_state right before prompt execution + const origOnSerialize = nodeType.prototype.onSerialize; + nodeType.prototype.onSerialize = function (o) { + if (this.saveCanvasState) { + this.saveCanvasState(); + } + return origOnSerialize ? origOnSerialize.apply(this, arguments) : undefined; + }; + + // Suppress native ComfyUI output images preview (which draws duplicate preview images below node) + nodeType.prototype.onDrawBackground = function () { + if (this.flags?.collapsed) return; + if (this.imgs) this.imgs = null; + }; + + // Execution Handler - do NOT call origOnExecuted as it triggers native preview images at bottom + nodeType.prototype.onExecuted = function (message) { + this.hideOutputImages = true; + this.imgs = null; + if (!message || !message.images || !Array.isArray(message.images)) return; + + const node = this; + message.images.forEach(imgMeta => { + const url = api.apiURL( + `/view?filename=${encodeURIComponent(imgMeta.filename)}` + + `&type=${encodeURIComponent(imgMeta.type || "temp")}` + + `&subfolder=${encodeURIComponent(imgMeta.subfolder || "")}` + + `&t=${Date.now()}` + ); + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const slot = imgMeta.slot || ""; + if (slot === "Background" || slot.toLowerCase() === "background") { + node.onBackgroundLoaded(img); + } else if (slot.startsWith("layer_")) { + node.onLayerImageLoaded(slot, img); + } + }; + img.src = url; + }); + }; + + nodeType.prototype.tryLoadUpstreamImage = function (slotName) { + const node = this; + if (!node.inputs || !app.graph) return; + const inp = node.inputs.find(i => i.name === slotName); + if (!inp || inp.link == null) return; + const link = app.graph.links[inp.link]; + if (!link) return; + const originNode = app.graph.getNodeById(link.origin_id); + if (!originNode) return; + + let imgSrc = null; + if (originNode.imgs && originNode.imgs.length > 0 && originNode.imgs[0]?.src) { + imgSrc = originNode.imgs[0].src; + } else if (originNode.widgets) { + const imgWidget = originNode.widgets.find(w => w.name === "image" || w.name === "image_path"); + if (imgWidget && imgWidget.value) { + imgSrc = api.apiURL( + `/view?filename=${encodeURIComponent(imgWidget.value)}` + + `&type=input` + + `&t=${Date.now()}` + ); + } + } + + if (!imgSrc) return; + + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + if (slotName === "Background") { + node.onBackgroundLoaded(img); + } else if (slotName.startsWith("layer_")) { + node.onLayerImageLoaded(slotName, img); + } + }; + img.src = imgSrc; + }; + + const origOnConnectionsChange = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function (type, index, isConnected, linkInfo) { + if (origOnConnectionsChange) { + origOnConnectionsChange.apply(this, arguments); + } + if (type === 1) { // Input connection + if (!isConnected) { + const inp = this.inputs && this.inputs[index]; + const slotName = inp ? inp.name : null; + if (slotName === "Background" || index === this.findInputSlot("Background")) { + this.bgImage = null; + this.setCanvasDimensions(this.currentW || 640, this.currentH || 360); + } else if (slotName && slotName.startsWith("layer_")) { + this.canvasLayers = (this.canvasLayers || []).filter(l => l.id !== slotName); + if (this.activeLayerId === slotName) { + this.activeLayerId = null; + } + } + } + this.manageDynamicSlots(); + if (isConnected && this.inputs && this.inputs[index]) { + this.tryLoadUpstreamImage(this.inputs[index].name); + } + this.saveCanvasState(); + this.renderPreviewDeck(); + this.renderStage(); + } + }; + + nodeType.prototype.manageDynamicSlots = function () { + let maxConnectedLayer = 0; + if (this.inputs) { + for (let i = 0; i < this.inputs.length; i++) { + const input = this.inputs[i]; + const hasLink = input.link != null && (!app.graph || !app.graph.links || app.graph.links[input.link] != null); + if (input.name.startsWith("layer_") && hasLink) { + const num = parseInt(input.name.split("_")[1], 10); + if (num > maxConnectedLayer) maxConnectedLayer = num; + } + } + } + + // Always provide at least layer_1, plus next available slot up to layer_4 + const desiredSlots = Math.min(5, Math.max(1, maxConnectedLayer + 1)); + for (let i = 2; i <= desiredSlots; i++) { + const name = `layer_${i}`; + if (!this.inputs.find(inp => inp.name === name)) { + this.addInput(name, "IMAGE"); + } + } + + // Clean up trailing unconnected slots beyond desiredSlots + if (this.inputs) { + for (let i = this.inputs.length - 1; i >= 0; i--) { + const input = this.inputs[i]; + if (input.name.startsWith("layer_")) { + const num = parseInt(input.name.split("_")[1], 10); + const hasLink = input.link != null && (!app.graph || !app.graph.links || app.graph.links[input.link] != null); + if (num > desiredSlots && !hasLink) { + this.removeInput(i); + } + } + } + } + }; + } +}); + +api.addEventListener("executed", (e) => { + const detail = e?.detail; + if (!detail || !app.graph) return; + const canvasNodes = app.graph.findNodesByType("AILab_Canvas"); + if (!canvasNodes) return; + for (const cNode of canvasNodes) { + if (!cNode.inputs) continue; + for (const inp of cNode.inputs) { + if (inp.link != null) { + const link = app.graph.links[inp.link]; + if (link && link.origin_id === detail.node) { + cNode.tryLoadUpstreamImage(inp.name); + } + } + } + } +}); diff --git a/web/js/appearance.js b/web/js/appearance.js index 51c1989..9d29366 100644 --- a/web/js/appearance.js +++ b/web/js/appearance.js @@ -3,6 +3,7 @@ import { app } from "/scripts/app.js"; const COLOR_THEMES = { segment: { nodeColor: "#222e40", nodeBgColor: "#364254", width: 340 }, utility: { nodeColor: "#2e3e57", nodeBgColor: "#4b5b73", width: 300 }, + canvas: { nodeColor: "#222e40", nodeBgColor: "#364254", width: 640 }, }; const NODE_COLORS = { @@ -22,6 +23,10 @@ const NODE_COLORS = { "SAM2SegmentDiscoveryAdv": "segment", "SAM3Segment": "segment", "AILab_Florence2": "segment", + "AILab_Yolo": "segment", + "AILab_VRMBG": "segment", + "AILab_FeyNoBg": "segment", + "AILab_FinegrainBoxSegment": "segment", // Utility nodes "AILab_LoadImage": "utility", @@ -54,6 +59,7 @@ const NODE_COLORS = { "AILab_ColorToMask": "utility", "AILab_Resolution_Selector": "utility", "AILab_ImageCompareView": "utility", + "AILab_Canvas": "canvas", }; function setNodeColors(node, theme) { @@ -83,4 +89,74 @@ const ext = { } }; +export const SLOT_AFFINITY = { + rules: { + input: { + IMAGE: [ + "AILab_ImagePreview", + "AILab_Preview", + "AILab_ImageCompareView", + "RMBG" + ], + MASK: [ + "AILab_MaskPreview", + "AILab_Preview", + "AILab_MaskOverlay" + ], + BBOX: [ + "AILab_FinegrainBoxSegment" + ], + BBOXES: [ + "AILab_FinegrainBoxSegment" + ] + }, + output: { + IMAGE: [ + "AILab_LoadImage", + "AILab_LoadImageSimple", + "AILab_LoadImageAdvanced" + ], + MASK: [ + "RMBG", + "AILab_ColorToMask", + "SAM3Segment" + ], + BBOX: [ + "AILab_Florence2", + "SAM3Segment" + ], + BBOXES: [ + "AILab_Florence2", + "SAM3Segment" + ] + } + }, + metrics: { + "AILab_Preview": 4600, + "AILab_ImagePreview": 4500, + "AILab_LoadImageSimple": 4500, + "RMBG": 4500, + "AILab_LoadImage": 4400, + "AILab_LoadImageAdvanced": 4300, + "AILab_ColorToMask": 4200, + "SAM3Segment": 4200, + "AILab_Florence2": 4200, + "AILab_FinegrainBoxSegment": 4200 + }, + defaultSlots: { + out: { + IMAGE: ["AILab_ImagePreview"], + MASK: ["AILab_MaskPreview"], + BBOX: ["AILab_FinegrainBoxSegment"], + BBOXES: ["AILab_FinegrainBoxSegment"] + }, + in: { + IMAGE: ["AILab_LoadImage"], + MASK: ["RMBG"], + BBOX: ["AILab_Florence2", "SAM3Segment"], + BBOXES: ["AILab_Florence2", "SAM3Segment"] + } + } +}; + app.registerExtension(ext); diff --git a/web/js/colorWidget.js b/web/js/colorWidget.js index 4f59156..65cebcf 100644 --- a/web/js/colorWidget.js +++ b/web/js/colorWidget.js @@ -3,7 +3,7 @@ * * This integration script is licensed under the GNU General Public License v3.0 (GPL-3.0). * If you incorporate or modify this code, please credit AILab as the original source: - * https://github.com/1038lab + * https://github.com/1038lab/ComfyUI-RMBG */ import { app } from "/scripts/app.js"; @@ -59,12 +59,12 @@ function hsvToRGB(h, s, v) { const x = c * (1 - Math.abs((h / 60) % 2 - 1)); const m = v - c; let r, g, b; - if (h < 60) { r = c; g = x; b = 0; } + if (h < 60) { r = c; g = x; b = 0; } else if (h < 120) { r = x; g = c; b = 0; } else if (h < 180) { r = 0; g = c; b = x; } else if (h < 240) { r = 0; g = x; b = c; } else if (h < 300) { r = x; g = 0; b = c; } - else { r = c; g = 0; b = x; } + else { r = c; g = 0; b = x; } return { r: Math.round((r + m) * 255), g: Math.round((g + m) * 255), @@ -111,6 +111,8 @@ function openColorPicker(initialColor, clientX, clientY, onApply) { panel.className = 'ailab-cp-panel'; Object.assign(panel.style, { position: 'fixed', zIndex: '99999', + width: `${SV_SIZE + PAD * 2}px`, + boxSizing: 'border-box', background: '#1e1e1e', border: '1px solid #444', borderRadius: '8px', padding: `${PAD}px`, boxShadow: '0 8px 32px rgba(0,0,0,0.6)', @@ -136,36 +138,79 @@ function openColorPicker(initialColor, clientX, clientY, onApply) { marginBottom: `${GAP}px`, }); - // Bottom row: preview swatch + hex input + OK button + // Bottom row: preview swatch + eyedropper + hex input + OK button const row = document.createElement('div'); Object.assign(row.style, { - display: 'flex', alignItems: 'center', gap: `${GAP}px`, + display: 'flex', alignItems: 'center', gap: '6px', + width: '100%', boxSizing: 'border-box', }); const preview = document.createElement('div'); Object.assign(preview.style, { - width: '28px', height: '28px', borderRadius: '4px', - border: '1px solid #555', flexShrink: '0', + width: '26px', height: '26px', borderRadius: '4px', + border: '1px solid #555', flexShrink: '0', boxSizing: 'border-box', }); + let dropBtn = null; + if (window.EyeDropper) { + dropBtn = document.createElement('button'); + dropBtn.type = 'button'; + dropBtn.title = 'Pick color from screen'; + Object.assign(dropBtn.style, { + width: '26px', height: '26px', borderRadius: '4px', + border: '1px solid #555', background: '#333', color: '#ccc', + flexShrink: '0', cursor: 'pointer', padding: '0', + display: 'flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', + transition: 'background 0.15s, border-color 0.15s, color 0.15s', + }); + dropBtn.innerHTML = ``; + dropBtn.addEventListener('mouseenter', () => { + dropBtn.style.background = '#444'; + dropBtn.style.borderColor = '#777'; + dropBtn.style.color = '#fff'; + }); + dropBtn.addEventListener('mouseleave', () => { + dropBtn.style.background = '#333'; + dropBtn.style.borderColor = '#555'; + dropBtn.style.color = '#ccc'; + }); + dropBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + const eyeDropper = new window.EyeDropper(); + const result = await eyeDropper.open(); + if (result && result.sRGBHex) { + hsv = hexToHSV(result.sRGBHex); + updateAll(); + } + } catch (err) {} + }); + } + const hexInput = document.createElement('input'); hexInput.type = 'text'; Object.assign(hexInput.style, { - flex: '1', background: '#333', border: '1px solid #555', - borderRadius: '4px', color: '#eee', padding: '4px 8px', - fontFamily: 'monospace', fontSize: '13px', outline: 'none', - width: '80px', minWidth: '0', + flex: '1', width: '0', minWidth: '0', + height: '26px', boxSizing: 'border-box', + background: '#333', border: '1px solid #555', + borderRadius: '4px', color: '#eee', padding: '0 4px', + fontFamily: 'monospace', fontSize: '11px', outline: 'none', + textAlign: 'center', }); const okBtn = document.createElement('button'); okBtn.textContent = 'OK'; Object.assign(okBtn.style, { + height: '26px', boxSizing: 'border-box', background: '#4a9eff', color: '#fff', border: 'none', - borderRadius: '4px', padding: '5px 14px', cursor: 'pointer', - fontSize: '12px', fontWeight: 'bold', + borderRadius: '4px', padding: '0 10px', cursor: 'pointer', + fontSize: '11px', fontWeight: 'bold', flexShrink: '0', }); - row.append(preview, hexInput, okBtn); + row.append(preview); + if (dropBtn) row.append(dropBtn); + row.append(hexInput, okBtn); panel.append(svCanvas, hueCanvas, row); // ── Position panel below click point, clamped to viewport ── diff --git a/web/js/utils.js b/web/js/utils.js new file mode 100644 index 0000000..c03c8cd --- /dev/null +++ b/web/js/utils.js @@ -0,0 +1,285 @@ +import { app } from "/scripts/app.js"; +import { SLOT_AFFINITY } from "./appearance.js"; + +const EXTENSION_NAME = "AILab.WorkflowUtils"; + +export function clamp(val, min, max) { + return Math.min(Math.max(val, min), max); +} + +export function debounce(fn, delay = 200) { + let timer = null; + return function (...args) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; +} + +export function hexToRgb(hex) { + if (!hex) return [0, 0, 0]; + const clean = hex.replace("#", ""); + const bigint = parseInt(clean, 16); + if (clean.length === 6) { + return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255]; + } + return [0, 0, 0]; +} + +const g = typeof window !== "undefined" ? window : globalThis; +g.__AILAB_WORKFLOW_UTILS__ = g.__AILAB_WORKFLOW_UTILS__ || { + configs: [], + register(cfg) { + if (cfg) this.configs.push(cfg); + }, + getMergedRules() { + const merged = { input: {}, output: {} }; + for (const c of this.configs) { + for (const dir of ["input", "output"]) { + const rules = c.rules?.[dir] || {}; + for (const [slot, list] of Object.entries(rules)) { + merged[dir][slot] = merged[dir][slot] || []; + for (const name of list) { + if (!merged[dir][slot].includes(name)) { + merged[dir][slot].push(name); + } + } + } + } + } + return merged; + }, + getMergedMetrics() { + const merged = {}; + for (const c of this.configs) { + Object.assign(merged, c.metrics || {}); + } + return merged; + }, + isMatchingNode(node) { + if (!node) return false; + const name = node.name || node.type || node.comfyClass || ""; + if (name.startsWith("AILab_") || name === "RMBG" || name.startsWith("SAM")) return true; + const category = node.category || ""; + if (category.includes("AILab") || category.includes("RMBG")) return true; + const displayName = node.display_name || ""; + return displayName.includes("(RMBG)") || displayName.includes("AILab"); + } +}; + +if (SLOT_AFFINITY) { + g.__AILAB_WORKFLOW_UTILS__.register(SLOT_AFFINITY); +} + +if (typeof window !== "undefined" && window.fetch && !g.__AILAB_WORKFLOW_UTILS__._fetchHooked) { + const origFetch = window.fetch; + window.fetch = async function (...args) { + const res = await origFetch.apply(this, args); + const url = typeof args[0] === "string" ? args[0] : (args[0]?.url || ""); + if (url.includes("sorted-custom-node-map.json")) { + try { + const clone = res.clone(); + const data = await clone.json(); + Object.assign(data, g.__AILAB_WORKFLOW_UTILS__.getMergedMetrics()); + return new Response(JSON.stringify(data), { + status: res.status, + statusText: res.statusText, + headers: res.headers + }); + } catch (_) { + return res; + } + } + return res; + }; + g.__AILAB_WORKFLOW_UTILS__._fetchHooked = true; +} + +function reorderResults(results, filters, query) { + if (!Array.isArray(results) || results.length === 0) return results; + + const trimmed = (query || "").trim().toLowerCase(); + const hasSlot = filters && filters.length > 0; + + let dir = ""; + let slotType = ""; + + if (hasSlot) { + const f = filters.find(item => { + const fid = item.filterDef?.id || item.id || ""; + const seq = item.filterDef?.invokeSequence || ""; + return fid === "input" || fid === "output" || seq === "i" || seq === "o"; + }); + if (f) { + const fid = f.filterDef?.id || f.id || ""; + const seq = f.filterDef?.invokeSequence || ""; + dir = (fid === "input" || seq === "i") ? "input" : "output"; + slotType = String(f.value || "").toUpperCase(); + } + } + + const mergedRules = g.__AILAB_WORKFLOW_UTILS__.getMergedRules(); + + if (dir && slotType && (!trimmed || trimmed === "rmbg" || trimmed === "ailab")) { + const priority = mergedRules[dir]?.[slotType] || []; + if (priority.length > 0) { + const pinned = []; + const rest = []; + + for (const name of priority) { + const found = results.find(n => (n.name || n.type) === name); + if (found) pinned.push(found); + } + + const pinnedSet = new Set(pinned.map(p => p.name || p.type)); + + for (const item of results) { + const name = item.name || item.type; + if (!pinnedSet.has(name)) { + rest.push(item); + } + } + + if (dir === "output") { + const previewSet = new Set(["AILab_ImagePreview", "AILab_Preview", "AILab_MaskPreview"]); + const normal = []; + const previews = []; + for (const item of rest) { + if (previewSet.has(item.name || item.type)) { + previews.push(item); + } else { + normal.push(item); + } + } + return [...pinned, ...normal, ...previews]; + } + + if (pinned.length > 0) { + return [...pinned, ...rest]; + } + } + } + + if (trimmed && (trimmed === "rmbg" || trimmed === "sam" || trimmed === "ailab")) { + const matched = []; + const normal = []; + + for (const item of results) { + if (g.__AILAB_WORKFLOW_UTILS__.isMatchingNode(item)) { + matched.push(item); + } else { + normal.push(item); + } + } + + if (dir && slotType && matched.length > 0) { + const priority = mergedRules[dir]?.[slotType] || []; + matched.sort((a, b) => { + const aIdx = priority.indexOf(a.name || a.type || ""); + const bIdx = priority.indexOf(b.name || b.type || ""); + return (aIdx !== -1 ? aIdx : 999) - (bIdx !== -1 ? bIdx : 999); + }); + } + + return [...matched, ...normal]; + } + + return results; +} + +function hookService(service) { + if (!service || service._ailabHooked) return; + + const ServiceClass = service.constructor; + const proto = (ServiceClass && ServiceClass.prototype && typeof ServiceClass.prototype.searchNode === "function") + ? ServiceClass.prototype + : service; + + const origSearch = proto.searchNode; + if (typeof origSearch !== "function") return; + + proto.searchNode = function (query, filters = [], options = {}, extra = {}) { + const origLimit = options?.limit || 64; + const opt = { ...options }; + if (opt.limit) delete opt.limit; + + const full = origSearch.call(this, query, filters, opt, extra); + const reordered = reorderResults(full, filters, query); + return reordered.slice(0, origLimit); + }; + + service._ailabHooked = true; + if (ServiceClass && ServiceClass.prototype) { + ServiceClass.prototype._ailabHooked = true; + } +} + +function connectService() { + try { + const vue = document.getElementById("vue-app")?.__vue_app__; + let pinia = vue?.config?.globalProperties?.$pinia; + + if (!pinia && vue?._context?.provides) { + for (const sym of Object.getOwnPropertySymbols(vue._context.provides)) { + const val = vue._context.provides[sym]; + if (val && typeof val === "object" && val._s instanceof Map) { + pinia = val; + break; + } + } + } + + if (pinia && pinia._s instanceof Map) { + const store = pinia._s.get("nodeDef"); + if (store?.nodeSearchService) { + hookService(store.nodeSearchService); + return true; + } + } + } catch (_) { } + return false; +} + +function initLiteGraphDefaults() { + if (typeof LiteGraph === "undefined") return; + + LiteGraph.slot_types_default_out = LiteGraph.slot_types_default_out || {}; + LiteGraph.slot_types_default_in = LiteGraph.slot_types_default_in || {}; + + for (const c of g.__AILAB_WORKFLOW_UTILS__.configs) { + if (c.defaultSlots?.out) { + for (const [slot, list] of Object.entries(c.defaultSlots.out)) { + const cur = LiteGraph.slot_types_default_out[slot] || []; + const add = list.filter(n => !cur.includes(n)); + if (add.length > 0) { + LiteGraph.slot_types_default_out[slot] = [...add, ...cur]; + } + } + } + if (c.defaultSlots?.in) { + for (const [slot, list] of Object.entries(c.defaultSlots.in)) { + const cur = LiteGraph.slot_types_default_in[slot] || []; + const add = list.filter(n => !cur.includes(n)); + if (add.length > 0) { + LiteGraph.slot_types_default_in[slot] = [...add, ...cur]; + } + } + } + } +} + +app.registerExtension({ + name: EXTENSION_NAME, + async setup() { + initLiteGraphDefaults(); + + let attempts = 0; + const timer = setInterval(() => { + attempts++; + if (connectService() || attempts > 25) { + clearInterval(timer); + } + }, 200); + + window.addEventListener("pointerdown", () => connectService(), { passive: true }); + } +});