From 671d9e50bdf41d2d30ef904fc4942d7550ec60f3 Mon Sep 17 00:00:00 2001 From: ku1x Date: Thu, 23 Jul 2026 15:31:48 +0200 Subject: [PATCH] fix: unify coordinate system between preview canvas and Python output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root cause was a coordinate space mismatch between the floating panel canvas and the Python backend: Before (broken): - Canvas mask window: fixed ~300px (arbitrary, unrelated to actual mask) - Preview image: drawn at downscaled pixel size (e.g. 768x512) - offsetX/Y: interpreted in preview image pixel space - Python output: interprets offsetX/Y in ORIGINAL image pixel space - Result: 100px offset in preview = 200px in output (2x mismatch at 50% scale) After (fixed): - Python sends orig_h/w (original image dims) + bbox_h/w (mask bbox) via mce_info - Canvas computes zoom-to-fit so the full original image is visible - Everything rendered in ORIGINAL image pixel space (zoomed for display) - Mask window size = bbox_w * zoom × bbox_h * zoom (accurate proportional size) - DrawImage stretches the preview to fill original dimensions (consistent coords) - Mouse drag sensitivity = 1 / (zoom * scale) for correct image-pixel mapping - offsetX/Y values in JS == same values applied by Python (no conversion needed) This ensures WYSIWYG: what you see in the mask window IS what you get in output. --- js/mask_canvas_editor.js | 389 +++++++++++++++++---------------- py/nodes/mask_canvas_editor.py | 67 ++---- 2 files changed, 226 insertions(+), 230 deletions(-) diff --git a/js/mask_canvas_editor.js b/js/mask_canvas_editor.js index 52c1392..c0478ff 100644 --- a/js/mask_canvas_editor.js +++ b/js/mask_canvas_editor.js @@ -2,11 +2,11 @@ * Mask Canvas Editor — Frontend Extension * * Compact node with a floating DOM-based canvas editor panel. - * Inspired by ComfyUI-ZhiHui's drawing board pattern: - * - Node body is small (240×80), shows a button + thumbnail - * - Floating window with full interactive canvas opens on click - * - Drag to pan, scroll to zoom, shift+scroll to rotate - * - Flip/reset toolbar, Apply button writes state back + * Coordinate system is in ORIGINAL-IMAGE pixel space: + * - Zoom-to-fit base transform so the full image is visible on canvas + * - Mask window drawn at actual bbox size (from Python metadata) + * - offsetX/Y are in original image pixel space (consistent with Python) + * - All transforms (scale, rotation, flip) work in the same space */ import { app } from "../../scripts/app.js"; @@ -23,9 +23,9 @@ const DEFAULT_STATE = { }; const PANEL_W = 800; -const PANEL_H = 560; +const PANEL_H = 600; const CANVAS_W = 960; -const CANVAS_H = 540; +const CANVAS_H = 600; // ───────────────────────────────────────────────────────────── // Extension @@ -36,7 +36,6 @@ app.registerExtension({ async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== NODE_TYPE) return; - // ── onNodeCreated ────────────────────────────────────── const origOnCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { origOnCreated?.apply(this, arguments); @@ -45,6 +44,11 @@ app.registerExtension({ this.__mce_img = null; this.__mce_imgW = 0; this.__mce_imgH = 0; + this.__mce_origW = 0; + this.__mce_origH = 0; + this.__mce_bboxW = 0; + this.__mce_bboxH = 0; + this.__mce_zoom = 1; // Hidden bridge widget for transform_state const sw = this.addWidget("text", "transform_state", "{}", () => {}, {}); @@ -53,7 +57,6 @@ app.registerExtension({ sw.draw = () => {}; sw.serializeValue = () => JSON.stringify(this.__mce_state); - // Restore saved state try { const v = sw.value; if (v && v !== "{}") { @@ -61,12 +64,10 @@ app.registerExtension({ } } catch (_) {} - // Add the open button widget this.addWidget("button", "🎨 Open Canvas Editor", null, () => { this._openEditor(); }); - // Node size hint at bottom this.onResize?.(this.size); }; @@ -82,20 +83,17 @@ app.registerExtension({ } }; - // ── onDrawForeground (tiny thumbnail preview) ────────── + // ── onDrawForeground (thumbnail preview on compact node) ── const origFg = nodeType.prototype.onDrawForeground; nodeType.prototype.onDrawForeground = function (ctx) { origFg?.apply(this, arguments); if (this.flags?.collapsed) return; - - // Draw a small thumbnail if we have a preview image if (this.__mce_img && this.__mce_img.complete && this.__mce_img.naturalWidth > 0) { const W = this.size[0]; const H = this.size[1]; - const thumbH = H - 38; // leave room for button widget + const thumbH = H - 38; if (thumbH > 20) { ctx.save(); - // Clip to rounded rect const r = 4; ctx.beginPath(); ctx.moveTo(r, 20); ctx.lineTo(W - r, 20); @@ -108,30 +106,29 @@ app.registerExtension({ ctx.quadraticCurveTo(0, 20, r, 20); ctx.closePath(); ctx.clip(); - - // Draw image scaled to fit - const iw = this.__mce_imgW; - const ih = this.__mce_imgH; - const scale = Math.min(W / iw, thumbH / ih, 1); - const dw = iw * scale; - const dh = ih * scale; - const dx = (W - dw) / 2; - const dy = 20 + (thumbH - dh) / 2; - ctx.fillStyle = "#11111b"; ctx.fillRect(0, 20, W, thumbH); - ctx.drawImage(this.__mce_img, dx, dy, dw, dh); - + const scale = Math.min(W / this.__mce_imgW, thumbH / this.__mce_imgH, 1); + const dw = this.__mce_imgW * scale; + const dh = this.__mce_imgH * scale; + ctx.drawImage(this.__mce_img, (W - dw) / 2, 20 + (thumbH - dh) / 2, dw, dh); ctx.restore(); } } }; - // ── onExecuted ───────────────────────────────────────── + // ── onExecuted: receive preview + coordinate info ────── const origExec = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function (message) { origExec?.apply(this, arguments); const preview = message?.mce_preview?.[0]; + const info = message?.mce_info?.[0]; + if (info) { + this.__mce_origW = info.orig_w || 0; + this.__mce_origH = info.orig_h || 0; + this.__mce_bboxW = info.bbox_w || 0; + this.__mce_bboxH = info.bbox_h || 0; + } if (!preview) return; const url = `/view?filename=${preview.filename}&subfolder=${preview.subfolder || ""}&type=${preview.type || "temp"}&t=${Date.now()}`; if (url === this.__mce_previewUrl) return; @@ -155,10 +152,9 @@ app.registerExtension({ } const self = this; - const img = this.__mce_img; const state = this.__mce_state; - // ── Create the floating panel ── + // ── Panel DOM ── const panel = document.createElement("div"); panel.id = `mce-panel-${this.id}`; panel.style.cssText = ` @@ -188,40 +184,28 @@ app.registerExtension({ title.style.cssText = "font-weight:700;font-size:15px;color:#cdd6f4;margin-right:8px;"; header.appendChild(title); - // Flip buttons - const flipHBtn = document.createElement("button"); - flipHBtn.textContent = state.flipH ? "↔ H ✓" : "↔ H"; - flipHBtn.style.cssText = btnStyle(state.flipH ? "#89b4fa" : "#313244", state.flipH ? "#1e1e2e" : "#cdd6f4"); - flipHBtn.onclick = () => { state.flipH = !state.flipH; flipHBtn.textContent = state.flipH ? "↔ H ✓" : "↔ H"; updateBtnStyle(flipHBtn, state.flipH ? "#89b4fa" : "#313244", state.flipH ? "#1e1e2e" : "#cdd6f4"); renderPanelCanvas(); }; - header.appendChild(flipHBtn); - - const flipVBtn = document.createElement("button"); - flipVBtn.textContent = state.flipV ? "↕ V ✓" : "↕ V"; - flipVBtn.style.cssText = btnStyle(state.flipV ? "#a6e3a1" : "#313244", state.flipV ? "#1e1e2e" : "#cdd6f4"); - flipVBtn.onclick = () => { state.flipV = !state.flipV; flipVBtn.textContent = state.flipV ? "↕ V ✓" : "↕ V"; updateBtnStyle(flipVBtn, state.flipV ? "#a6e3a1" : "#313244", state.flipV ? "#1e1e2e" : "#cdd6f4"); renderPanelCanvas(); }; - header.appendChild(flipVBtn); - - // Reset button - const resetBtn = document.createElement("button"); - resetBtn.textContent = "↺ Reset"; - resetBtn.style.cssText = btnStyle("#45475a", "#cdd6f4"); - resetBtn.onclick = () => { + const flipHBtn = makeBtn(state.flipH ? "↔ H ✓" : "↔ H", + state.flipH ? "#89b4fa" : "#313244", + state.flipH ? "#1e1e2e" : "#cdd6f4", + () => { state.flipH = !state.flipH; updateFlipBtns(); render(); }); + const flipVBtn = makeBtn(state.flipV ? "↕ V ✓" : "↕ V", + state.flipV ? "#a6e3a1" : "#313244", + state.flipV ? "#1e1e2e" : "#cdd6f4", + () => { state.flipV = !state.flipV; updateFlipBtns(); render(); }); + const resetBtn = makeBtn("↺ Reset", "#45475a", "#cdd6f4", () => { state.scale = 1; state.rotation = 0; state.flipH = false; state.flipV = false; state.offsetX = 0; state.offsetY = 0; - flipHBtn.textContent = "↔ H"; updateBtnStyle(flipHBtn, "#313244", "#cdd6f4"); - flipVBtn.textContent = "↕ V"; updateBtnStyle(flipVBtn, "#313244", "#cdd6f4"); - updateInfo(); - renderPanelCanvas(); - }; + updateFlipBtns(); syncSliders(); updateInfo(); render(); + }); + header.appendChild(flipHBtn); + header.appendChild(flipVBtn); header.appendChild(resetBtn); - // Spacer const spacer = document.createElement("div"); spacer.style.flex = "1"; header.appendChild(spacer); - // Close button const closeBtn = document.createElement("button"); closeBtn.textContent = "✕"; closeBtn.style.cssText = ` @@ -231,7 +215,6 @@ app.registerExtension({ closeBtn.onclick = () => closePanel(false); header.appendChild(closeBtn); - // Drag to move header.onpointerdown = (e) => { if (e.target.closest("button")) return; e.preventDefault(); @@ -241,15 +224,13 @@ app.registerExtension({ panel.style.left = Math.max(0, ev.clientX - sx) + "px"; panel.style.top = Math.max(0, ev.clientY - sy) + "px"; }; - const onUp = () => { - document.removeEventListener("pointermove", onMove); - document.removeEventListener("pointerup", onUp); - }; document.addEventListener("pointermove", onMove); - document.addEventListener("pointerup", onUp); + document.addEventListener("pointerup", () => { + document.removeEventListener("pointermove", onMove); + }, { once: true }); }; - // ── Body: Canvas area ── + // ── Body ── const body = document.createElement("div"); body.style.cssText = "flex:1;min-height:0;display:flex;overflow:hidden;"; @@ -259,13 +240,11 @@ app.registerExtension({ display:flex; align-items:center; justify-content:center; background:#11111b; `; - const canvas = document.createElement("canvas"); canvas.style.cssText = "cursor:grab;"; canvas.width = CANVAS_W; canvas.height = CANVAS_H; canvasWrap.appendChild(canvas); - body.appendChild(canvasWrap); // ── Sidebar ── @@ -277,21 +256,39 @@ app.registerExtension({ gap:12px; font-size:13px; `; + function getInfoText() { + const oW = self.__mce_origW || self.__mce_imgW || 0; + const oH = self.__mce_origH || self.__mce_imgH || 0; + const bW = self.__mce_bboxW || 0; + const bH = self.__mce_bboxH || 0; + let t = ""; + if (oW && oH) t += `Image: ${oW}×${oH}\n`; + if (bW && bH) t += `Mask: ${bW}×${bH}\n`; + t += `Display: ${CANVAS_W}×${CANVAS_H}`; + return t; + } + sidebar.innerHTML = `
Controls
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
${getInfoText()}
🖱 Drag to pan
🖱 Scroll to zoom
🖱 Shift+Scroll to rotate
-
The mask is the fixed window —
the background image moves behind it.
+
⌨ R to reset
+
Mask window size = actual crop output size.
`; @@ -304,49 +301,53 @@ app.registerExtension({ padding:10px 14px; background:#181825; border-top:1px solid #313244; flex-shrink:0; `; - const statusText = document.createElement("span"); statusText.style.cssText = "font-size:11px;color:#6c7086;flex:1;"; - statusText.textContent = "Position the image behind the mask"; + statusText.textContent = "Preview matches output"; footer.appendChild(statusText); + footer.appendChild(makeBtn("Cancel", "#313244", "#cdd6f4", () => closePanel(false))); + footer.appendChild(makeBtn("✅ Apply", "#89b4fa", "#1e1e2e", () => closePanel(true))); - const cancelBtn = document.createElement("button"); - cancelBtn.textContent = "Cancel"; - cancelBtn.style.cssText = btnStyle("#313244", "#cdd6f4"); - cancelBtn.onclick = () => closePanel(false); - footer.appendChild(cancelBtn); - - const applyBtn = document.createElement("button"); - applyBtn.textContent = "✅ Apply"; - applyBtn.style.cssText = ` - padding:8px 20px; border-radius:8px; border:none; - background:#89b4fa; color:#1e1e2e; cursor:pointer; - font-weight:600; font-size:13px; - `; - applyBtn.onclick = () => closePanel(true); - footer.appendChild(applyBtn); - - // ── Assemble ── panel.appendChild(header); panel.appendChild(body); panel.appendChild(footer); document.body.appendChild(panel); - // ── Helper functions ── - function updateInfo() { - const els = { - "mce-val-scale": state.scale.toFixed(2), - "mce-val-rot": state.rotation.toFixed(1) + "°", - "mce-val-ox": state.offsetX, - "mce-val-oy": state.offsetY, - }; - for (const [id, val] of Object.entries(els)) { - const el = panel.querySelector(`#${id}`); - if (el) el.textContent = val; - } + // ── Helper state ── + self.__mce_zoom = 1; // will be recomputed in render() + + function updateFlipBtns() { + flipHBtn.textContent = state.flipH ? "↔ H ✓" : "↔ H"; + flipHBtn.style.background = state.flipH ? "#89b4fa" : "#313244"; + flipHBtn.style.color = state.flipH ? "#1e1e2e" : "#cdd6f4"; + flipVBtn.textContent = state.flipV ? "↕ V ✓" : "↕ V"; + flipVBtn.style.background = state.flipV ? "#a6e3a1" : "#313244"; + flipVBtn.style.color = state.flipV ? "#1e1e2e" : "#cdd6f4"; } - function renderPanelCanvas() { + function updateInfo() { + setText("mce-v-scale", state.scale.toFixed(2)); + setText("mce-v-rot", state.rotation.toFixed(1) + "°"); + setText("mce-v-ox", state.offsetX); + setText("mce-v-oy", state.offsetY); + const b = panel.querySelector("#mce-info-box"); + if (b) b.textContent = getInfoText(); + } + + function setText(id, val) { + const el = panel.querySelector(`#${id}`); + if (el) el.textContent = val; + } + + function syncSliders() { + panel.querySelector("#mce-s-scale").value = state.scale; + panel.querySelector("#mce-s-rot").value = state.rotation; + panel.querySelector("#mce-s-ox").value = state.offsetX; + panel.querySelector("#mce-s-oy").value = state.offsetY; + } + + // ── Canvas render (unified coordinate system) ───────── + function render() { const ctx = canvas.getContext("2d"); const W = CANVAS_W, H = CANVAS_H; ctx.clearRect(0, 0, W, H); @@ -354,38 +355,60 @@ app.registerExtension({ ctx.fillRect(0, 0, W, H); const cx = W / 2, cy = H / 2; - const mw = Math.min(W * 0.5, H * 0.5, 300); - const mh = mw; - const mx = cx - mw / 2, my = cy - mh / 2; - // ── Draw background image with transforms ── + // Original image dimensions (in actual image pixel space) + const origW = self.__mce_origW || self.__mce_imgW || W; + const origH = self.__mce_origH || self.__mce_imgH || H; + // Mask bbox dimensions (in actual image pixel space) + const bboxW = self.__mce_bboxW || Math.round(Math.min(W * 0.3, 200)); + const bboxH = self.__mce_bboxH || Math.round(Math.min(H * 0.3, 200)); + + // Zoom-to-fit: show the full image centered in the canvas + // This is the base zoom — 1 "image pixel" = zoom "canvas pixels" + const zoom = Math.max(0.05, Math.min( + W * 0.85 / Math.max(origW, 1), + H * 0.85 / Math.max(origH, 1), + 5.0 + )); + self.__mce_zoom = zoom; + + // ── Draw image with transforms ── ctx.save(); ctx.translate(cx, cy); - ctx.scale(state.scale, state.scale); - ctx.rotate((state.rotation * Math.PI) / 180); - if (state.flipH) ctx.scale(-1, 1); - if (state.flipV) ctx.scale(1, -1); - ctx.translate(state.offsetX, state.offsetY); + ctx.scale(zoom, zoom); // base zoom (fit image to canvas) + ctx.scale(state.scale, state.scale); // user scale + ctx.rotate((state.rotation * Math.PI) / 180); // user rotation + if (state.flipH) ctx.scale(-1, 1); // user flip H + if (state.flipV) ctx.scale(1, -1); // user flip V + ctx.translate(state.offsetX, state.offsetY); // user offset (in image pixels) if (self.__mce_img && self.__mce_img.complete && self.__mce_img.naturalWidth > 0) { - ctx.drawImage(self.__mce_img, -self.__mce_imgW / 2, -self.__mce_imgH / 2, self.__mce_imgW, self.__mce_imgH); + // Draw preview image stretched to original image pixel dimensions + // (ctx.drawImage handles the stretch) + ctx.drawImage(self.__mce_img, -origW / 2, -origH / 2, origW, origH); + // Image edge highlight ctx.strokeStyle = "rgba(203,166,247,0.2)"; - ctx.lineWidth = 1; - ctx.strokeRect(-self.__mce_imgW / 2, -self.__mce_imgH / 2, self.__mce_imgW, self.__mce_imgH); + ctx.lineWidth = 1 / (zoom * Math.max(state.scale, 0.01)); + ctx.strokeRect(-origW / 2, -origH / 2, origW, origH); } else { - // Grid fallback - drawGrid(ctx, W, H); + drawGrid(ctx, W / zoom, H / zoom); } ctx.restore(); - // ── Mask overlay ── + // ── Mask window (canvas pixel space) ── + const mw = bboxW * zoom; + const mh = bboxH * zoom; + const mx = cx - mw / 2; + const my = cy - mh / 2; + + // Semi-transparent overlay outside mask ctx.fillStyle = "rgba(0,0,0,0.55)"; ctx.fillRect(0, 0, W, my); ctx.fillRect(0, my + mh, W, H - (my + mh)); ctx.fillRect(0, my, mx, mh); ctx.fillRect(mx + mw, my, W - (mx + mw), mh); - // Mask border + // Mask border (dashed) ctx.strokeStyle = "#89b4fa"; ctx.lineWidth = 2; ctx.setLineDash([6, 3]); @@ -395,14 +418,17 @@ app.registerExtension({ // Corner markers ctx.strokeStyle = "#cba6f7"; ctx.lineWidth = 3; - const cl = 16; - const c = (x, y, dx1, dy1, dx2, dy2) => { - ctx.beginPath(); ctx.moveTo(x + dx1, y + dy1); ctx.lineTo(x, y); ctx.lineTo(x + dx2, y + dy2); ctx.stroke(); - }; - c(mx, my, cl, 0, 0, cl); - c(mx + mw, my, -cl, 0, 0, cl); - c(mx, my + mh, cl, 0, 0, -cl); - c(mx + mw, my + mh, -cl, 0, 0, -cl); + const cl = Math.min(16, mw * 0.2, mh * 0.2); + if (cl >= 6) { + const corner = (x, y, dx1, dy1, dx2, dy2) => { + ctx.beginPath(); ctx.moveTo(x + dx1, y + dy1); + ctx.lineTo(x, y); ctx.lineTo(x + dx2, y + dy2); ctx.stroke(); + }; + corner(mx, my, cl, 0, 0, cl); + corner(mx + mw, my, -cl, 0, 0, cl); + corner(mx, my + mh, cl, 0, 0, -cl); + corner(mx + mw, my + mh, -cl, 0, 0, -cl); + } // Crosshair ctx.strokeStyle = "rgba(203,166,247,0.35)"; @@ -413,18 +439,19 @@ app.registerExtension({ ctx.stroke(); ctx.setLineDash([]); - // Info label + // Info label (bottom-left of mask) ctx.fillStyle = "rgba(198,160,246,0.7)"; ctx.font = "10px sans-serif"; ctx.textAlign = "left"; ctx.fillText( - `${Math.round(mw)}×${Math.round(mh)} S:${state.scale.toFixed(2)} R:${state.rotation.toFixed(0)}°${state.flipH?" H":""}${state.flipV?" V":""}`, + `Mask:${bboxW}×${bboxH} S:${state.scale.toFixed(2)} R:${state.rotation.toFixed(0)}°` + + (state.flipH ? " H" : "") + (state.flipV ? " V" : ""), mx + 4, my + mh + 14 ); // Canvas size label (bottom-right) const label = `${CANVAS_W} × ${CANVAS_H}`; - ctx.font = "bold 12px monospace"; + ctx.font = "bold 11px monospace"; const lw = ctx.measureText(label).width; ctx.fillStyle = "rgba(0,0,0,0.45)"; ctx.fillRect(W - lw - 14, H - 26, lw + 10, 20); @@ -434,66 +461,64 @@ app.registerExtension({ ctx.textAlign = "start"; } - // ── Slider events ── - const bindSlider = (slId, valId, key, parseFn, formatFn) => { + // ── Slider binding ── + function bindSlider(slId, valId, key, parseFn, fmtFn) { const sl = panel.querySelector(`#${slId}`); - const vl = panel.querySelector(`#${valId}`); if (!sl) return; sl.addEventListener("input", () => { - const v = parseFn(sl.value); - state[key] = v; - if (vl) vl.textContent = formatFn ? formatFn(v) : v; - renderPanelCanvas(); + state[key] = parseFn(sl.value); + const v = panel.querySelector(`#${valId}`); + if (v) v.textContent = fmtFn(state[key]); + render(); }); - }; - bindSlider("mce-sl-scale", "mce-val-scale", "scale", parseFloat, (v) => v.toFixed(2)); - bindSlider("mce-sl-rot", "mce-val-rot", "rotation", parseFloat, (v) => v.toFixed(1) + "°"); - bindSlider("mce-sl-ox", "mce-val-ox", "offsetX", parseInt, (v) => v); - bindSlider("mce-sl-oy", "mce-val-oy", "offsetY", parseInt, (v) => v); + } + bindSlider("mce-s-scale", "mce-v-scale", "scale", parseFloat, (v) => v.toFixed(2)); + bindSlider("mce-s-rot", "mce-v-rot", "rotation", parseFloat, (v) => v.toFixed(1) + "°"); + bindSlider("mce-s-ox", "mce-v-ox", "offsetX", (v) => parseInt(v) || 0, (v) => v); + bindSlider("mce-s-oy", "mce-v-oy", "offsetY", (v) => parseInt(v) || 0, (v) => v); - // ── Canvas mouse interaction ── - let dragging = false, dragSX = 0, dragSY = 0, dragOX = 0, dragOY = 0; + // ── Canvas mouse drag ── + let dragging = false, dsx = 0, dsy = 0, dox = 0, doy = 0; canvas.addEventListener("mousedown", (e) => { dragging = true; - dragSX = e.clientX; dragSY = e.clientY; - dragOX = state.offsetX; dragOY = state.offsetY; + dsx = e.clientX; dsy = e.clientY; + dox = state.offsetX; doy = state.offsetY; canvas.style.cursor = "grabbing"; }); window.addEventListener("mousemove", (e) => { if (!dragging) return; - const sens = 1 / Math.max(state.scale, 0.1); - state.offsetX = Math.round(dragOX + (e.clientX - dragSX) * sens); - state.offsetY = Math.round(dragOY + (e.clientY - dragSY) * sens); - state.offsetX = Math.max(-8192, Math.min(8192, state.offsetX)); - state.offsetY = Math.max(-8192, Math.min(8192, state.offsetY)); - panel.querySelector("#mce-sl-ox").value = state.offsetX; - panel.querySelector("#mce-sl-oy").value = state.offsetY; + // Drag sensitivity: screen delta → image pixel delta + // 1 screen pixel = 1/(zoom * scale) image pixels + const sens = 1 / (self.__mce_zoom * Math.max(state.scale, 0.1)); + const dx = Math.round((e.clientX - dsx) * sens); + const dy = Math.round((e.clientY - dsy) * sens); + state.offsetX = Math.max(-8192, Math.min(8192, dox + dx)); + state.offsetY = Math.max(-8192, Math.min(8192, doy + dy)); + syncSliders(); updateInfo(); - renderPanelCanvas(); + render(); }); window.addEventListener("mouseup", () => { - if (dragging) { - dragging = false; - canvas.style.cursor = "grab"; - } + if (dragging) { dragging = false; canvas.style.cursor = "grab"; } }); + // ── Canvas wheel ── canvas.addEventListener("wheel", (e) => { e.preventDefault(); if (e.shiftKey) { state.rotation += e.deltaY > 0 ? -5 : 5; state.rotation = Math.max(-180, Math.min(180, state.rotation)); - panel.querySelector("#mce-sl-rot").value = state.rotation; + panel.querySelector("#mce-s-rot").value = state.rotation; } else { state.scale += e.deltaY > 0 ? -0.05 : 0.05; state.scale = Math.max(0.01, Math.min(10, Math.round(state.scale * 100) / 100)); - panel.querySelector("#mce-sl-scale").value = state.scale; + panel.querySelector("#mce-s-scale").value = state.scale; } updateInfo(); - renderPanelCanvas(); + render(); }, { passive: false }); // ── Keyboard ── @@ -504,15 +529,13 @@ app.registerExtension({ } if (e.key === "Escape") closePanel(false); if (e.key === "Enter" && !e.shiftKey) closePanel(true); - if (e.key === "r" && !e.ctrlKey && !e.metaKey) { resetBtn.onclick(); } + if (e.key === "r" && !e.ctrlKey && !e.metaKey) resetBtn.onclick(); }; document.addEventListener("keydown", onKeyDown); - // ── Close panel ── + // ── Close ── function closePanel(apply) { if (apply) { - self.__mce_state = state; - // Write to hidden bridge widget const sw = self.widgets?.find((w) => w.name === "transform_state"); if (sw) sw.value = JSON.stringify(state); if (self.graph) self.graph._version = (self.graph._version || 0) + 1; @@ -522,28 +545,26 @@ app.registerExtension({ self.__mce_panel = null; } - // ── Initial render ── - renderPanelCanvas(); + // ── Kick off ── + render(); }; }, }); -// ── Utility functions ───────────────────────────────────── -function btnStyle(bg, fg) { - return `padding:6px 12px;border-radius:6px;border:1px solid rgba(69,71,90,.4);background:${bg};color:${fg};cursor:pointer;font-size:12px;font-weight:600;`; -} - -function updateBtnStyle(btn, bg, fg) { - btn.style.background = bg; - btn.style.color = fg; +// ── Utilities ───────────────────────────────────────────── +function makeBtn(label, bg, fg, onClick) { + const btn = document.createElement("button"); + btn.textContent = label; + btn.style.cssText = `padding:6px 12px;border-radius:6px;border:1px solid rgba(69,71,90,.4);background:${bg};color:${fg};cursor:pointer;font-size:12px;font-weight:600;`; + btn.onclick = onClick; + return btn; } function drawGrid(ctx, W, H) { const ext = Math.max(W, H) * 2; - const step = 64; - const s = -ext, e = ext; - for (let y = s; y < e; y += step) { - for (let x = s; x < e; x += step) { + const step = Math.max(32, Math.min(128, Math.min(W, H) / 8)); + for (let y = -ext; y < ext; y += step) { + for (let x = -ext; x < ext; x += step) { ctx.fillStyle = ((Math.floor(x / step) + Math.floor(y / step)) & 1) === 0 ? "#2a2a3a" : "#1e1e2e"; ctx.fillRect(x, y, step, step); } @@ -551,8 +572,8 @@ function drawGrid(ctx, W, H) { ctx.strokeStyle = "rgba(137,180,250,0.12)"; ctx.lineWidth = 1; ctx.beginPath(); - for (let x = s; x <= e; x += step) { ctx.moveTo(x, s); ctx.lineTo(x, e); } - for (let y = s; y <= e; y += step) { ctx.moveTo(s, y); ctx.lineTo(e, y); } + for (let x = -ext; x <= ext; x += step) { ctx.moveTo(x, -ext); ctx.lineTo(x, ext); } + for (let y = -ext; y <= ext; y += step) { ctx.moveTo(-ext, y); ctx.lineTo(ext, y); } ctx.stroke(); ctx.strokeStyle = "rgba(137,180,250,0.45)"; ctx.lineWidth = 2; diff --git a/py/nodes/mask_canvas_editor.py b/py/nodes/mask_canvas_editor.py index 7d43a08..3b81ecc 100644 --- a/py/nodes/mask_canvas_editor.py +++ b/py/nodes/mask_canvas_editor.py @@ -20,13 +20,6 @@ import folder_paths class MaskCanvasEditor: - """ - Interactive canvas editor — the node body IS the editor. - - The mask defines a fixed "window" at the center. The background image - is transformed behind it. All parameters (scale, rotation, flip, offset) - are controlled by direct canvas interaction, not slider widgets. - """ @classmethod def INPUT_TYPES(cls): @@ -54,17 +47,12 @@ class MaskCanvasEditor: @classmethod def IS_CHANGED(cls, **kwargs): - ts = kwargs.get("transform_state", "{}") - return ts + return kwargs.get("transform_state", "{}") - # ------------------------------------------------------------------ - # Helper: bounding box from a binary mask - # ------------------------------------------------------------------ @staticmethod def _get_bbox(mask: torch.Tensor): rows = torch.any(mask > 0.5, dim=1) cols = torch.any(mask > 0.5, dim=0) - if rows.any() and cols.any(): row_indices = torch.where(rows)[0] col_indices = torch.where(cols)[0] @@ -75,12 +63,8 @@ class MaskCanvasEditor: else: y_min, y_max = 0, mask.shape[0] x_min, x_max = 0, mask.shape[1] - return y_min, y_max, x_min, x_max - # ------------------------------------------------------------------ - # Helper: build the sampling grid - # ------------------------------------------------------------------ @staticmethod def _build_sampling_grid( bbox_h: int, bbox_w: int, @@ -135,22 +119,17 @@ class MaskCanvasEditor: return torch.stack([gx, gy], dim=-1).unsqueeze(0) - # ------------------------------------------------------------------ - # Save input image preview for the frontend canvas - # ------------------------------------------------------------------ @staticmethod - def _save_preview(image: torch.Tensor, unique_id: str) -> str: + def _save_preview(image: torch.Tensor, unique_id: str) -> tuple: """ - Save a downsampled version of the input image to ComfyUI's temp directory. - Returns the filename. + Save a downsampled copy of the input image to ComfyUI's temp directory. + Returns (filename, orig_h, orig_w) where orig_h/w are the original + image dimensions (pre-downscale). """ - # Get first frame: (H, W, C) float [0,1] img_np = image[0].cpu().numpy() - h, w = img_np.shape[:2] - # Downsample for preview (max edge = 1024px) - max_size = 1024 + max_size = 2048 if max(h, w) > max_size: ratio = max_size / max(h, w) new_w = max(1, int(w * ratio)) @@ -160,15 +139,11 @@ class MaskCanvasEditor: else: img_pil = Image.fromarray((img_np * 255).clip(0, 255).astype(np.uint8)) - # Save to ComfyUI temp folder filename = f"mce_bg_{unique_id}.png" filepath = os.path.join(folder_paths.get_temp_directory(), filename) img_pil.save(filepath, compress_level=1) - return filename + return filename, h, w - # ------------------------------------------------------------------ - # Main processing - # ------------------------------------------------------------------ def process( self, mask: torch.Tensor, @@ -176,18 +151,6 @@ class MaskCanvasEditor: transform_state: str = "{}", unique_id: str = "0", ): - """ - Args: - mask: (B, H, W) mask defining the crop region - image: (B, H, W, C) background image - transform_state: JSON string from the JS canvas editor - unique_id: ComfyUI node ID (hidden input) - - Returns: - cropped_image: (B, H', W', C) - cropped_mask: (B, H', W') - """ - # Parse transform state from JS frontend state = json.loads(transform_state) if transform_state else {} scale = float(state.get("scale", 1.0)) rotation = float(state.get("rotation", 0.0)) @@ -196,8 +159,7 @@ class MaskCanvasEditor: offset_x = int(state.get("offsetX", 0)) offset_y = int(state.get("offsetY", 0)) - # Save input image preview for the frontend canvas - preview_filename = self._save_preview(image, unique_id) + preview_filename, orig_h, orig_w = self._save_preview(image, unique_id) batch_size = mask.shape[0] device = image.device @@ -205,6 +167,9 @@ class MaskCanvasEditor: cropped_images = [] cropped_masks = [] + # Track info from first batch item for frontend metadata + ui_info = {} + for b in range(batch_size): m = mask[b] img = image[b] @@ -215,6 +180,15 @@ class MaskCanvasEditor: bbox_h = y_max - y_min bbox_w = x_max - x_min + # Store bbox info from first iteration + if b == 0: + ui_info = { + "orig_h": orig_h, + "orig_w": orig_w, + "bbox_h": bbox_h, + "bbox_w": bbox_w, + } + m_cropped = m[y_min:y_max, x_min:x_max] grid = self._build_sampling_grid( @@ -249,6 +223,7 @@ class MaskCanvasEditor: "subfolder": "", "type": "temp", }], + "mce_info": [ui_info], }, "result": ( torch.stack(cropped_images, dim=0),