fix: unify coordinate system between preview canvas and Python output
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.
This commit is contained in:
+205
-184
@@ -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 = `
|
||||
<div style="font-weight:600;font-size:14px;border-bottom:1px solid #313244;padding-bottom:8px;">Controls</div>
|
||||
<div><label style="display:flex;justify-content:space-between;">Scale <span id="mce-val-scale" style="color:#89b4fa;">${state.scale.toFixed(2)}</span></label>
|
||||
<input type="range" id="mce-sl-scale" min="0.1" max="5" step="0.01" value="${state.scale}" style="width:100%;accent-color:#89b4fa;"></div>
|
||||
<div><label style="display:flex;justify-content:space-between;">Rotation <span id="mce-val-rot" style="color:#a6e3a1;">${state.rotation.toFixed(1)}°</span></label>
|
||||
<input type="range" id="mce-sl-rot" min="-180" max="180" step="0.5" value="${state.rotation}" style="width:100%;accent-color:#a6e3a1;"></div>
|
||||
<div><label style="display:flex;justify-content:space-between;">Offset X <span id="mce-val-ox" style="color:#fab387;">${state.offsetX}</span></label>
|
||||
<input type="range" id="mce-sl-ox" min="-2048" max="2048" step="1" value="${state.offsetX}" style="width:100%;accent-color:#fab387;"></div>
|
||||
<div><label style="display:flex;justify-content:space-between;">Offset Y <span id="mce-val-oy" style="color:#f9e2af;">${state.offsetY}</span></label>
|
||||
<input type="range" id="mce-sl-oy" min="-2048" max="2048" step="1" value="${state.offsetY}" style="width:100%;accent-color:#f9e2af;"></div>
|
||||
<div><label style="display:flex;justify-content:space-between;">Scale <span id="mce-v-scale" style="color:#89b4fa;">${state.scale.toFixed(2)}</span></label>
|
||||
<input type="range" id="mce-s-scale" min="0.1" max="5" step="0.01" value="${state.scale}" style="width:100%;accent-color:#89b4fa;"></div>
|
||||
<div><label style="display:flex;justify-content:space-between;">Rotation <span id="mce-v-rot" style="color:#a6e3a1;">${state.rotation.toFixed(1)}°</span></label>
|
||||
<input type="range" id="mce-s-rot" min="-180" max="180" step="0.5" value="${state.rotation}" style="width:100%;accent-color:#a6e3a1;"></div>
|
||||
<div><label style="display:flex;justify-content:space-between;">Offset X <span id="mce-v-ox" style="color:#fab387;">${state.offsetX}</span></label>
|
||||
<input type="range" id="mce-s-ox" min="-8192" max="8192" step="1" value="${state.offsetX}" style="width:100%;accent-color:#fab387;"></div>
|
||||
<div><label style="display:flex;justify-content:space-between;">Offset Y <span id="mce-v-oy" style="color:#f9e2af;">${state.offsetY}</span></label>
|
||||
<input type="range" id="mce-s-oy" min="-8192" max="8192" step="1" value="${state.offsetY}" style="width:100%;accent-color:#f9e2af;"></div>
|
||||
<pre id="mce-info-box" style="
|
||||
background:#11111b; border-radius:6px; padding:8px;
|
||||
font-size:11px; line-height:1.5; color:#6c7086;
|
||||
margin:0; white-space:pre-wrap;
|
||||
">${getInfoText()}</pre>
|
||||
<div style="margin-top:auto;padding-top:10px;border-top:1px solid #313244;color:#6c7086;font-size:11px;line-height:1.5;">
|
||||
<div>🖱 <b>Drag</b> to pan</div>
|
||||
<div>🖱 <b>Scroll</b> to zoom</div>
|
||||
<div>🖱 <b>Shift+Scroll</b> to rotate</div>
|
||||
<div style="margin-top:6px;">The mask is the fixed window —<br>the background image moves behind it.</div>
|
||||
<div>⌨ <b>R</b> to reset</div>
|
||||
<div style="margin-top:6px;">Mask window size = actual crop output size.</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user