feat: MaskEditor round-trip — per-slot mask persistence (Phase 2 complete)

Wire the per-slot mask button to ComfyUI's MaskEditor (frontend 1.45):
point the editor at the slot image via node.images + previewMediaType,
open it through the Comfy.MaskEditor.OpenMaskEditor command, poll for
the saved clipspace ref, bake the alpha channel into a grayscale mask
(white = painted) and POST it to /grid_pool/set_mask.

Also fixes DOM-widget sizing for frontend 1.45: size via the getMinHeight
option (the computeLayoutSize path) with NO max, so the grid fills and
grows with the node instead of detaching/locking on click; hide pool_id
via widget.hidden; suppress node.imgs so a registered output never
reserves a preview strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 15:03:39 +02:00
parent 6feb2c6e63
commit a6ed79aabc
+179 -10
View File
@@ -107,13 +107,26 @@ function recomputeSize(node, count) {
node._gridWidgetH = 2 * MARGIN + TOOLBAR_H + 6 + Math.min(full, cap); node._gridWidgetH = 2 * MARGIN + TOOLBAR_H + 6 + Math.min(full, cap);
} }
function applySize(node) { // Grow the node to fit new content, but never shrink below the user's current
if (node._gridEl) node._gridEl.style.maxHeight = `${node._gridGridMax || 300}px`; // size (so a manual resize is respected) and never below the content floor.
function resizeToContent(node) {
const want = node.computeSize(); const want = node.computeSize();
node.setSize([Math.max(node.size?.[0] || MIN_W, MIN_W), want[1]]); const h = Math.max(node.size?.[1] || 0, want[1]);
node.setSize([Math.max(node.size?.[0] || MIN_W, MIN_W), h]);
node.setDirtyCanvas(true, true); node.setDirtyCanvas(true, true);
} }
// Keep _gridWidgetH current every refresh (so the getMinHeight floor is always
// right), but only physically resize the node when the image count changes —
// never on a plain select or label edit.
function maybeResize(node, count) {
recomputeSize(node, count);
if (count !== node._lastCount) {
node._lastCount = count;
requestAnimationFrame(() => resizeToContent(node));
}
}
async function refresh(node) { async function refresh(node) {
const grid = node._gridEl; const grid = node._gridEl;
if (!grid) return; if (!grid) return;
@@ -130,9 +143,12 @@ async function refresh(node) {
const bust = Date.now(); const bust = Date.now();
grid.innerHTML = ""; grid.innerHTML = "";
// size the node to fit the (new) content before/while rendering // stash for the mask-editor button (needs the slot's image filename + pool id)
recomputeSize(node, slots.length); node._slots = slots;
applySize(node); node._poolId = poolId;
// keep computeSize current; only physically resize when the count changes
maybeResize(node, slots.length);
if (slots.length === 0) { if (slots.length === 0) {
const empty = document.createElement("div"); const empty = document.createElement("div");
@@ -264,6 +280,135 @@ function wireIngest(node, container, uploadBtn, fileInput) {
}; };
} }
// ---- mask editor (Phase 2) --------------------------------------------------
// Opens ComfyUI's built-in MaskEditor for a slot and stores the painted mask
// per-slot. Frontend 1.45 exposes no callback, so we point the editor at our
// slot image via node.images, open it through the registered command, and poll
// node.images for the editor's saved clipspace ref on save.
function comfyAppClass() {
try { return app.constructor; } catch (e) { return null; }
}
function blobToImage(blob) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(blob);
});
}
// The MaskEditor registers the painted image as this node's *output*. ComfyUI's
// nodeOutputStore is keyed by NodeLocatorId (String(node.id) for root-graph
// nodes, "<graphId>:<id>" inside subgraphs). Clear both the outputs and any
// preview-image entries so nothing repopulates node.imgs.
function clearNodeOutputs(node) {
try {
for (const map of [app.nodeOutputs, app.nodePreviewImages]) {
if (!map) continue;
for (const k of Object.keys(map)) {
if (k === String(node.id) || k.endsWith(`:${node.id}`)) delete map[k];
}
}
} catch (e) { /* best effort */ }
}
// drop the transient hints we set so the editor's source image never lingers as
// a preview on our grid node (node.imgs itself is permanently suppressed below)
function cleanupMaskState(node) {
if (node._maskPoll) { clearInterval(node._maskPoll); node._maskPoll = null; }
node._maskSlot = null;
try {
node.images = undefined;
node.previewMediaType = undefined;
} catch (e) { /* best effort */ }
clearNodeOutputs(node);
node.setDirtyCanvas?.(true, true);
}
async function captureMask(node, slot, ref) {
try {
const sub = ref.subfolder ?? "clipspace";
const type = ref.type ?? "input";
const url = `/view?filename=${encodeURIComponent(ref.filename)}&subfolder=${encodeURIComponent(sub)}&type=${encodeURIComponent(type)}&r=${Date.now()}`;
const resp = await api.fetchApi(url);
const blob = await resp.blob();
const img = await blobToImage(blob);
const c = document.createElement("canvas");
c.width = img.naturalWidth || img.width;
c.height = img.naturalHeight || img.height;
const ctx = c.getContext("2d");
ctx.drawImage(img, 0, 0);
const d = ctx.getImageData(0, 0, c.width, c.height);
const px = d.data;
// MaskEditor stores the mask in the ALPHA channel (opaque = painted). Bake
// alpha into a grayscale image so the backend (reads mask as L) sees
// white = painted region of interest. If polarity is reversed in practice,
// flip to `255 - a` here.
for (let i = 0; i < px.length; i += 4) {
const a = px[i + 3];
px[i] = px[i + 1] = px[i + 2] = a;
px[i + 3] = 255;
}
ctx.putImageData(d, 0, 0);
const maskBlob = await new Promise((res) => c.toBlob(res, "image/png"));
const fd = new FormData();
fd.append("pool_id", getPoolId(node));
fd.append("index", String(slot));
fd.append("mask", maskBlob, "mask.png");
await api.fetchApi(`${R}/set_mask`, { method: "POST", body: fd });
} catch (e) {
console.error("[gip] mask capture failed", e);
} finally {
cleanupMaskState(node);
await refresh(node);
}
}
function openMaskEditorForSlot(node, index) {
const slot = (node._slots || [])[index];
if (!slot) return;
cleanupMaskState(node);
const poolId = node._poolId || getPoolId(node);
// server reference the editor will load (no node.imgs -> no preview overlay)
node.images = [{ filename: slot.image, subfolder: `grid_pool/${poolId}`, type: "input" }];
node.previewMediaType = "image";
node.imageIndex = 0;
node._maskSlot = index;
const Comfy = comfyAppClass();
try { if (Comfy) Comfy.clipspace_return_node = node; } catch (e) { /* ignore */ }
// No save callback in 1.45 — poll for the editor writing the clipspace ref.
let waited = 0;
node._maskPoll = setInterval(() => {
waited += 300;
const ref = node.images && node.images[0];
if (node._maskSlot != null && ref && ref.subfolder === "clipspace") {
const slotIdx = node._maskSlot;
node._maskSlot = null;
clearInterval(node._maskPoll); node._maskPoll = null;
captureMask(node, slotIdx, ref);
} else if (waited > 10 * 60 * 1000) {
cleanupMaskState(node); // safety timeout (user cancelled long ago)
}
}, 300);
// select our node so the command targets it, then open the editor
try { app.canvas?.selectNode?.(node); } catch (e) { /* ignore */ }
const cmd = app.extensionManager?.command;
if (cmd?.execute) {
cmd.execute("Comfy.MaskEditor.OpenMaskEditor");
} else if (Comfy?.open_maskeditor) {
Comfy.open_maskeditor();
} else {
console.error("[gip] no MaskEditor entry point found");
cleanupMaskState(node);
}
}
// ---- node setup ------------------------------------------------------------- // ---- node setup -------------------------------------------------------------
function injectStyles() { function injectStyles() {
@@ -315,6 +460,20 @@ function setupGridNode(node) {
pw.value = (crypto.randomUUID && crypto.randomUUID()) || `p_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; pw.value = (crypto.randomUUID && crypto.randomUUID()) || `p_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
} }
// Our node draws its own grid; ComfyUI must never reserve/draw an output-image
// preview on it. The MaskEditor registers the painted image as this node's
// output, and the nodeOutputStore's syncLegacyNodeImgs would then set
// node.imgs — which reserves preview space at the top and shoves the widgets
// down (the "gap"/detach). Pin node.imgs to undefined so that can't happen.
// The editor still opens fine via node.images + previewMediaType.
try {
Object.defineProperty(node, "imgs", {
configurable: true,
get() { return undefined; },
set() { /* suppress output-image preview */ },
});
} catch (e) { /* ignore */ }
// build DOM // build DOM
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "gip-wrap"; wrap.className = "gip-wrap";
@@ -349,10 +508,18 @@ function setupGridNode(node) {
node._gridEl = grid; node._gridEl = grid;
node._countEl = count; node._countEl = count;
node._openMaskEditorForSlot = (i) => openMaskEditorForSlot(node, i);
const gridWidget = node.addDOMWidget("grid", "div", wrap, { serialize: false }); // Size the DOM widget through the OPTION ComfyUI's layout actually reads
// drive the node height from content so the toolbar never clips // (computeLayoutSize -> getMinHeight). Provide ONLY a min-height floor and NO
gridWidget.computeSize = (width) => [width, node._gridWidgetH || 200]; // getMaxHeight, so the grid FILLS the node and grows when the user resizes it.
// Pinning a max (or overriding widget.computeSize) locks the widget to a fixed
// size while the node frame keeps resizing — they diverge and the grid appears
// to detach / stop expanding on click.
node.addDOMWidget("grid", "div", wrap, {
serialize: false,
getMinHeight: () => node._gridWidgetH || 120,
});
wireIngest(node, grid, uploadBtn, fileInput); wireIngest(node, grid, uploadBtn, fileInput);
@@ -363,7 +530,9 @@ function setupGridNode(node) {
if (node._countEl) node._countEl.textContent = `${n} image${n === 1 ? "" : "s"}`; if (node._countEl) node._countEl.textContent = `${n} image${n === 1 ? "" : "s"}`;
}; };
// initial width + content-driven height // initial width + content-driven height (sized for empty; the first refresh
// resizes once if the pool already has images)
node._lastCount = 0;
recomputeSize(node, 0); recomputeSize(node, 0);
node.setSize([Math.max(node.size?.[0] || 0, MIN_W), node.computeSize()[1]]); node.setSize([Math.max(node.size?.[0] || 0, MIN_W), node.computeSize()[1]]);