2026-04-01 15:22:36 +08:00
2026-04-01 15:19:33 +08:00
2026-04-01 15:19:33 +08:00
2026-04-01 15:19:33 +08:00
2026-04-01 15:22:04 +08:00
2026-04-01 15:22:36 +08:00

import { app } from "../../scripts/app.js";

const MAX_MASKS = 8; const MASK_TYPE = "MASK"; const RES_COLOR = "#aaaaaa"; const OK_COLOR = "#22cc66"; const ERR_COLOR = "#cc3333"; const NA_COLOR = "#555555"; const ELSE_COLOR = "#cc9900";

const SLOT_FONT = "12px Arial"; const SLOT_NAME_X = 14; const DOT_RADIUS = 5; const NAME_GAP = 12; // gap: end of slot name → dot centre const DOT_GAP = 10; // gap: dot right edge → resolution text left edge

// LiteGraph draws output labels right-aligned from nodeW-10. // Our widest output name is "mask" or "rgba" (~30px in 12px Arial). // We leave 14px extra buffer, so output area = nodeW - 10 - 30 - 14 = nodeW - 54. // Resolution text right edge must stay at or left of this boundary. const OUTPUT_LABEL_RESERVE = 54;

const HELP_SECTIONS = [ { title: "Inputs", items: [ ["image", "Defines the target resolution for matching."], ["mask_1..8", "Connect as many masks as needed. A new slot appears as each one is connected."], ["else", "Fallback mask. Visible only when no_match is set to Use Else Mask."], ] }, { title: "Matching", items: [ ["✓ green", "Mask dimensions match the image exactly."], ["✗ red", "Mask dimensions do not match."], ["priority", "The lowest-numbered matching slot always wins."], ] }, { title: "multi_match", items: [ ["Use First Match", "Silently picks the lowest matching slot."], ["Raise Error", "Halts the queue and lists the conflicting slot numbers."], ] }, { title: "no_match", items: [ ["Output Empty Mask", "Outputs a zeroed mask (RGBA will be fully transparent)."], ["Use Else Mask", "Uses the else input as the fallback output."], ["Raise Error", "Halts the queue and lists the sizes that were connected."], ] }, { title: "Outputs", items: [ ["image", "Direct passthrough of the input image."], ["mask", "The winning mask (or fallback)."], ["rgba", "Input image + matched mask as alpha channel [B,H,W,4]."], ] }, ];

const HELP_BTN_W = 16; const HELP_BTN_H = 14; const HELP_BTN_R = 6;

let _helpPanel = null;

function getHelpPanel() { if (_helpPanel) return _helpPanel; const panel = document.createElement("div"); Object.assign(panel.style, { position: "fixed", zIndex: "9999", background: "#1a1a2e", border: "1px solid rgba(255,255,255,0.2)", borderRadius: "8px", padding: "14px 16px", width: "300px", maxHeight: "420px", overflowY: "auto", boxShadow: "0 8px 32px rgba(0,0,0,0.6)", color: "#ddd", fontFamily: "sans-serif", fontSize: "12px", lineHeight: "1.5", display: "none", userSelect: "none", }); let html = <div style="font-size:13px;font-weight:bold;color:#fff;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.15)"> AUTO MASK <span style="color:#a78bfa">(IMGLAB)</span> <span style="font-size:10px;color:#888;font-weight:normal;margin-left:6px">How it works</span> <span id="apm-help-close" style="float:right;cursor:pointer;color:#888;font-size:14px;line-height:1">✕</span> </div>; for (const section of HELP_SECTIONS) { html += <div style="margin-top:10px;margin-bottom:4px;font-size:11px;font-weight:bold;color:#8ab4f8;text-transform:uppercase;letter-spacing:0.05em">${section.title}</div>; html += <table style="width:100%;border-collapse:collapse">; for (const [key, val] of section.items) html += <tr><td style="color:#f0c040;white-space:nowrap;padding:2px 8px 2px 0;vertical-align:top;min-width:110px">${key}</td><td style="color:#ccc;padding:2px 0;vertical-align:top">${val}</td></tr>; html += </table>; } panel.innerHTML = html; document.body.appendChild(panel); panel.querySelector("#apm-help-close").addEventListener("click", () => panel.style.display = "none"); document.addEventListener("mousedown", (e) => { if (panel.style.display !== "none" && !panel.contains(e.target)) panel.style.display = "none"; }, true); _helpPanel = panel; return panel; }

function showHelpNear(sx, sy, sw) { const panel = getHelpPanel(); panel.style.display = "block"; const pw = panel.offsetWidth || 300, ph = panel.offsetHeight || 400; const vw = window.innerWidth, vh = window.innerHeight; let x = sx + sw + 10; if (x + pw > vw - 10) x = sx - pw - 10; let y = sy; if (y + ph > vh - 10) y = vh - ph - 10; if (y < 10) y = 10; panel.style.left = ${x}px; panel.style.top = ${y}px; }

app.registerExtension({ name: "IMGLAB.AutoMask",

async beforeRegisterNodeDef(nodeType, nodeData, app) {
    if (nodeData.name !== "AutoMask") return;

    const origCreated = nodeType.prototype.onNodeCreated;
    nodeType.prototype.onNodeCreated = function () {
        origCreated?.apply(this, arguments);
        // Default node colour — purple theme
        if (!this.color)    this.color   = "#2d1a4a";  // header — dark purple
        if (!this.bgcolor)  this.bgcolor = "#4a2878";  // body — original purple
        this._am_matchResults = {};
        this._am_imageSize    = null;
        this._am_maskSizes    = {};
        this._am_elseMaskSize = null;
        for (let i = this.inputs.length - 1; i >= 0; i--) {
            const n = this.inputs[i].name;
            if (/^mask_[2-9]$/.test(n) || /^mask_\d{2,}$/.test(n) || n === "else")
                this.removeInput(i);
        }
        this._am_watchNoMatchWidget();
    };

    nodeType.prototype._am_watchNoMatchWidget = function () {
        const widget = this.widgets?.find(w => w.name === "no_match");
        if (!widget) return;
        const self = this;
        const orig = widget.callback;
        widget.callback = function (v) { orig?.call(this, v); self._am_syncElseSlot(v); };
        this._am_syncElseSlot(widget.value);
    };

    nodeType.prototype._am_syncElseSlot = function (value) {
        const hasElse = this.inputs.some(i => i.name === "else");
        if (value === "Use Else Mask" && !hasElse) {
            this.addInput("else", MASK_TYPE);
            this.setSize(this.computeSize());
        } else if (value !== "Use Else Mask" && hasElse) {
            const idx = this.inputs.findIndex(i => i.name === "else");
            if (idx >= 0) { this.removeInput(idx); this.setSize(this.computeSize()); }
        }
        this.setDirtyCanvas(true, true);
    };

    nodeType.prototype._am_getMaskInputs = function () {
        return this.inputs
            .map((inp, idx) => ({ ...inp, _idx: idx }))
            .filter(inp => /^mask_\d+$/.test(inp.name))
            .sort((a, b) => parseInt(a.name.split("_")[1]) - parseInt(b.name.split("_")[1]));
    };

    nodeType.prototype._am_updateDynamicInputs = function () {
        const masks = this._am_getMaskInputs();
        let lastConnected = -1;
        for (let i = 0; i < masks.length; i++) if (masks[i].link != null) lastConnected = i;
        const targetCount = Math.min(lastConnected + 2, MAX_MASKS);
        for (let n = masks.length + 1; n <= targetCount; n++) this.addInput(`mask_${n}`, MASK_TYPE);
        const updated = this._am_getMaskInputs();
        for (let i = updated.length - 1; i >= targetCount; i--) {
            const gIdx = this.inputs.findIndex(inp => inp.name === updated[i].name);
            if (gIdx >= 0 && updated[i].link == null) this.removeInput(gIdx);
        }
        // Keep else at the bottom
        const elseIdx = this.inputs.findIndex(inp => inp.name === "else");
        if (elseIdx >= 0 && elseIdx !== this.inputs.length - 1) {
            this.removeInput(elseIdx);
            this.addInput("else", MASK_TYPE);
        }
        this.setSize(this.computeSize());
    };

    nodeType.prototype.onConnectionsChange = function (type, slotIndex, connected, link_info, input) {
        if (type !== 1) return;
        this._am_updateDynamicInputs();
        this.setDirtyCanvas(true, true);
    };

    nodeType.prototype.onExecuted = function (message) {
        if (message?.match_results?.[0]) {
            const raw = message.match_results[0];
            this._am_matchResults = {};
            for (const k in raw) this._am_matchResults[parseInt(k)] = raw[k];
        }
        if (message?.image_size?.[0])   this._am_imageSize = message.image_size[0];
        if (message?.mask_sizes?.[0]) {
            const raw = message.mask_sizes[0];
            this._am_maskSizes = {};
            for (const k in raw) this._am_maskSizes[parseInt(k)] = raw[k];
        }
        this._am_elseMaskSize = message?.else_mask_size?.[0] ?? null;
        this.setDirtyCanvas(true, true);
    };

    nodeType.prototype._am_helpBtnRect = function () {
        const titleH = LiteGraph.NODE_TITLE_HEIGHT;
        return {
            x: this.size[0] - HELP_BTN_R - HELP_BTN_W,
            y: -titleH + (titleH - HELP_BTN_H) / 2,
            w: HELP_BTN_W, h: HELP_BTN_H,
        };
    };

    const origDrawFg = nodeType.prototype.onDrawForeground;
    nodeType.prototype.onDrawForeground = function (ctx) {
        origDrawFg?.apply(this, arguments);

        // ? button
        const btn = this._am_helpBtnRect();
        ctx.beginPath(); ctx.roundRect(btn.x, btn.y, btn.w, btn.h, 4);
        ctx.fillStyle = "rgba(255,255,255,0.12)"; ctx.fill();
        ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.lineWidth = 1; ctx.stroke();
        ctx.font = "bold 11px sans-serif"; ctx.fillStyle = "rgba(255,255,255,0.75)";
        ctx.textAlign = "center";
        ctx.fillText("?", btn.x + btn.w / 2, btn.y + btn.h - 3);

        if (this.flags?.collapsed) return;

        const matchResults = this._am_matchResults || {};
        const maskSizes    = this._am_maskSizes    || {};
        const imageSize    = this._am_imageSize;
        const elseMaskSize = this._am_elseMaskSize;
        const nodeW        = this.size[0];

        // Compute shared dot column from widest mask_N name only.
        // image and else are shorter and would misalign the column.
        ctx.font = SLOT_FONT;
        let maxMaskNameW = ctx.measureText("mask_1").width; // minimum
        for (const inp of this.inputs) {
            if (/^mask_\d+$/.test(inp.name)) {
                const w = ctx.measureText(inp.name).width;
                if (w > maxMaskNameW) maxMaskNameW = w;
            }
        }
        const dotX = SLOT_NAME_X + maxMaskNameW + NAME_GAP + DOT_RADIUS;

        // Resolution text is right-aligned to just before the output label area.
        // Output labels ("image","mask","rgba") are at the right side of the node.
        // We reserve OUTPUT_LABEL_RESERVE px from the right for them.
        const resRight = nodeW - OUTPUT_LABEL_RESERVE;
		const dimX = dotX+DOT_GAP;
		
        const drawDot = (cx, cy, color, symbol) => {
            ctx.beginPath();
            ctx.arc(cx, cy, DOT_RADIUS, 0, Math.PI * 2);
            ctx.fillStyle = color; ctx.fill();
            if (symbol) {
                ctx.font = "bold 8px sans-serif"; ctx.fillStyle = "#fff";
                ctx.textAlign = "center";
                ctx.fillText(symbol, cx, cy + 3);
            }
        };

        const drawRes = (text, cy, color) => {
            ctx.font = "11px monospace"; ctx.fillStyle = color;
            ctx.textAlign = "left";
            ctx.fillText(text, dimX, cy + 4);
        };

        for (let i = 0; i < this.inputs.length; i++) {
            const inp       = this.inputs[i];
            const canvasPos = this.getConnectionPos(true, i);
            const cy        = canvasPos[1] - this.pos[1];

            // ── image ────────────────────────────────────────────────
            if (inp.name === "image") {
                if (imageSize) drawRes(`${imageSize[0]}×${imageSize[1]}`, cy, RES_COLOR);
                continue;
            }

            // ── else ─────────────────────────────────────────────────
            if (inp.name === "else") {
                ctx.font = SLOT_FONT;
                const elseNameW = ctx.measureText("else").width;
                const elseDotX  = SLOT_NAME_X + elseNameW + NAME_GAP + DOT_RADIUS;
                const connected = inp.link != null;
                drawDot(dotX, cy, connected ? ELSE_COLOR : NA_COLOR, connected && elseMaskSize ? "~" : null);
                if (connected && elseMaskSize) drawRes(`${elseMaskSize[0]}×${elseMaskSize[1]}`, cy, ELSE_COLOR);
                continue;
            }

            if (!/^mask_\d+$/.test(inp.name)) continue;

            const num       = parseInt(inp.name.split("_")[1]);
            const connected = inp.link != null;
            const hasResult = num in matchResults;
            const hasMaskSz = num in maskSizes;

            // dot at shared column
            if (connected && hasResult) {
                const isMatch = matchResults[num];
                drawDot(dotX, cy, isMatch ? OK_COLOR : ERR_COLOR, isMatch ? "✓" : "✗");
            } else if (!connected) {
                ctx.beginPath();
                ctx.arc(dotX, cy, DOT_RADIUS - 1, 0, Math.PI * 2);
                ctx.fillStyle = NA_COLOR; ctx.fill();
            }

            // resolution right-aligned before output labels
            if (connected && hasMaskSz) {
                const [mw, mh] = maskSizes[num];
                const isMatch  = hasResult ? matchResults[num] : null;
                drawRes(`${mw}×${mh}`, cy,
                    isMatch === true ? OK_COLOR : isMatch === false ? ERR_COLOR : RES_COLOR);
            }
        }
    };

    const origMouseDown = nodeType.prototype.onMouseDown;
    nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) {
        const btn = this._am_helpBtnRect();
        const [mx, my] = localPos;
        if (mx >= btn.x && mx <= btn.x + btn.w && my >= btn.y && my <= btn.y + btn.h) {
            const canvas = graphCanvas.canvas, rect = canvas.getBoundingClientRect();
            const scale = graphCanvas.ds?.scale ?? 1, offset = graphCanvas.ds?.offset ?? [0, 0];
            showHelpNear(
                rect.left + (this.pos[0] + offset[0]) * scale,
                rect.top  + (this.pos[1] + offset[1]) * scale,
                this.size[0] * scale
            );
            return true;
        }
        return origMouseDown?.call(this, e, localPos, graphCanvas);
    };
},

});

S
Description
No description provided
Readme
838 KiB
Languages
JavaScript 74.8%
Python 25.2%