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:
+179
-10
@@ -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]]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user