Files
2025-10-23 18:35:50 +02:00

574 lines
18 KiB
JavaScript

/* A simple image loop for your workflow. MIT License. version 0.2
https://github.com/Hullabalo/ComfyUI-Loop/
Thanks to rgthree, chrisgoringe, pythongosssss and many, many many others for their contributions, how-to's, code snippets etc.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// Constants
const CONSTANTS = {
NODE_NAME: "ImageCropLoop",
EXTENSION_NAME: "Comfy.ImagePreviewNode",
EVENT_NAME: "crop-bridge-proxy",
API_ENDPOINT: "/api/bridge/response",
WIDGET_NAMES: ["x", "y", "size", "color"],
MIN_NODE_SIZE: 300,
PREVIEW_HEIGHT: 300,
MARGIN: 10,
SIZE_INCREMENT: 8,
DEFAULT_VALUES: {
minSize: 256,
maxSize: 2048
}
};
// Utility functions
const Utils = {
clamp: (val, min, max) => Math.min(Math.max(val, min), max),
getImageBounds(img) {
if (!img?.complete || img?.naturalWidth <= 0) return null;
return { w: img.naturalWidth, h: img.naturalHeight };
},
calculateImageDimensions(img, availableWidth, availableHeight) {
const imgW = img.naturalWidth;
const imgH = img.naturalHeight;
const scale = Math.min(availableWidth / imgW, availableHeight / imgH, 1);
const scaledW = imgW * scale;
const scaledH = imgH * scale;
return { imgW, imgH, scale, scaledW, scaledH };
},
calculateMarkerBounds(widgetRefs, displayScale, scaledW, scaledH, previewScale) {
const x = widgetRefs.x?.value ?? 0;
const y = widgetRefs.y?.value ?? 0;
const size = widgetRefs.size?.value ?? CONSTANTS.DEFAULT_VALUES.minSize;
const markerSize = Math.min(size * displayScale, scaledW, scaledH);
const markerX = Utils.clamp(x * displayScale, 0, scaledW - markerSize);
const markerY = Utils.clamp(y * displayScale, 0, scaledH - markerSize);
return { markerX, markerY, markerSize };
},
constructImageURL(filename) {
return api.apiURL(
`/view?filename=${encodeURIComponent(filename)}&type=temp&subfolder=`
);
},
colorNameToRgba(colorName, alpha) {
const colors = {
red: "255, 0, 0",
green: "0, 255, 0",
blue: "0, 0, 255",
black: "0, 0, 0",
grey: "125, 125, 125"
};
const rgb = colors[colorName] ?? colors.red;
return `rgba(${rgb}, ${alpha})`;
},
hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
};
// Event handler for crop bridge proxy
api.addEventListener(CONSTANTS.EVENT_NAME, (event) => {
console.log('Image Crop - Received event:', event.detail);
const { detail: data } = event;
const nodeId = parseInt(data.id);
const targetNode = app.graph.getNodeById(nodeId);
targetNode?.updatePreview?.({
filename: data.name,
maskFilename: data.mask,
scale: data.scale,
original_width: data.original_width,
original_height: data.original_height
});
// // Send a response to backend (commented out for now)
// const reply = {
// id: data.id,
// modified_params: {
// x: targetNode?.widgetRefs?.x?.value ?? 128,
// y: targetNode?.widgetRefs?.y?.value ?? 64,
// size: targetNode?.widgetRefs?.size?.value ?? 512
// }
// };
// try {
// const response = await api.fetchApi(CONSTANTS.API_ENDPOINT, {
// method: "POST",
// headers: { "Content-Type": "application/json" },
// body: JSON.stringify(reply)
// });
// if (!response.ok) {
// console.error("Failed to send response to backend:", response.status);
// }
// } catch (error) {
// console.error("Error sending response to backend:", error);
// }
});
// Preview widget factory
const createPreviewWidget = (node) => ({
name: "preview",
type: "image_preview",
value: "",
height: CONSTANTS.PREVIEW_HEIGHT,
serializeValue: () => undefined,
draw(ctx, node, widgetWidth, y) {
const img = this.previewImage;
const availableWidth = widgetWidth - CONSTANTS.MARGIN * 2;
const availableHeight = node.size[1] - y - CONSTANTS.MARGIN * 2;
if (!img?.complete || img?.naturalWidth <= 0) {
drawPlaceholder(ctx, widgetWidth, y);
return;
}
drawImageWithMarker(ctx, node, img, availableWidth, availableHeight, y);
}
});
const drawPlaceholder = (ctx, widgetWidth, y) => {
ctx.fillStyle = "#666";
ctx.font = "14px Arial";
ctx.textAlign = "center";
ctx.fillText("Waiting for image...", widgetWidth / 2, y + 40);
};
const drawImageWithMarker = (ctx, node, img, availableWidth, availableHeight, y) => {
const dims = Utils.calculateImageDimensions(img, availableWidth, availableHeight);
const imgX = CONSTANTS.MARGIN + (availableWidth - dims.scaledW) / 2;
const imgY = y + CONSTANTS.MARGIN + (availableHeight - dims.scaledH) / 2;
const colorValue = node.widgetRefs.color?.value ?? "black";
const overlayColor = Utils.colorNameToRgba(colorValue, 0.5);
const previewScale = node.previewData?.scale ?? 1.0;
const displayScale = previewScale * dims.scale;
node.imageArea = {
x: imgX,
y: imgY,
width: dims.scaledW,
height: dims.scaledH,
scale: dims.scale,
displayScale: displayScale,
previewScale: previewScale
};
// Draw main image
ctx.drawImage(img, imgX, imgY, dims.scaledW, dims.scaledH);
// Draw mask overlay if available and show_mask is true
const showMask = node.widgetRefs.show_mask?.value ?? false;
if (showMask && node.previewWidget.previewMask) {
drawMaskOverlay(ctx, node.previewWidget.previewMask, imgX, imgY, dims.scaledW, dims.scaledH);
}
// Draw crop rectangle marker
const marker = Utils.calculateMarkerBounds(
node.widgetRefs,
displayScale,
dims.scaledW,
dims.scaledH,
dims.scale
);
const markerAbsX = imgX + marker.markerX;
const markerAbsY = imgY + marker.markerY;
ctx.save();
ctx.beginPath();
ctx.rect(imgX, imgY, dims.scaledW, dims.scaledH);
ctx.rect(markerAbsX + marker.markerSize, markerAbsY, -marker.markerSize, marker.markerSize);
ctx.closePath();
ctx.fillStyle = overlayColor;
ctx.fill();
ctx.restore();
};
const drawMaskOverlay = (ctx, maskImg, imgX, imgY, width, height) => {
ctx.save();
// ctx.globalAlpha = 0.8; // changing preview mask opacity made easy
ctx.drawImage(maskImg, imgX, imgY, width, height);
ctx.restore();
};
// Widget constraint handler
const setupWidgetConstraints = (node) => {
const redraw = () => node.previewWidget?.parent?.graph?.setDirtyCanvas(true);
CONSTANTS.WIDGET_NAMES.forEach(name => {
const widget = node.widgetRefs[name];
if (!widget) return;
const originalCallback = widget.callback;
let isUpdating = false;
widget.callback = (val) => {
if (isUpdating) return;
isUpdating = true;
try {
const bounds = {
w: node.previewData?.original_width ?? node.previewWidget?.previewImage?.naturalWidth,
h: node.previewData?.original_height ?? node.previewWidget?.previewImage?.naturalHeight
};
const values = updateWidgetValues(node.widgetRefs, name, val, bounds);
applyWidgetValues(node.widgetRefs, values);
originalCallback?.call(widget, values[name]);
redraw();
} catch (err) {
console.warn(`Error in widget ${name} callback:`, err);
} finally {
isUpdating = false;
}
};
});
// Setup show_mask widget observer
const showMaskWidget = node.widgetRefs.show_mask;
if (showMaskWidget) {
const originalShowMaskCallback = showMaskWidget.callback;
showMaskWidget.callback = (val) => {
originalShowMaskCallback?.call(showMaskWidget, val);
redraw();
};
}
};
const updateWidgetValues = (widgetRefs, changedWidget, newValue, bounds) => {
const values = {
x: widgetRefs.x?.value ?? 0,
y: widgetRefs.y?.value ?? 0,
size: widgetRefs.size?.value ?? CONSTANTS.DEFAULT_VALUES.minSize,
[changedWidget]: newValue
};
if (bounds && bounds.w && bounds.h) {
const { w: imgW, h: imgH } = bounds;
values.size = Utils.clamp(
values.size,
CONSTANTS.DEFAULT_VALUES.minSize,
Math.min(CONSTANTS.DEFAULT_VALUES.maxSize, imgW, imgH)
);
const maxX = imgW - values.size;
const maxY = imgH - values.size;
values.x = Utils.clamp(values.x, 0, maxX);
values.y = Utils.clamp(values.y, 0, maxY);
}
return values;
};
const applyWidgetValues = (widgetRefs, values) => {
widgetRefs.x && (widgetRefs.x.value = values.x);
widgetRefs.y && (widgetRefs.y.value = values.y);
widgetRefs.size && (widgetRefs.size.value = values.size);
};
const setupMouseHandler = (nodeType) => {
const originalOnMouseDown = nodeType.prototype.onMouseDown;
const originalOnMouseMove = nodeType.prototype.onMouseMove;
const originalOnMouseUp = nodeType.prototype.onMouseUp;
const originalOnMouseLeave = nodeType.prototype.onMouseLeave;
nodeType.prototype.onMouseDown = function(e, localPos, graphCanvas) {
const area = this.imageArea;
const { x: xWidget, y: yWidget, size: sizeWidget } = this.widgetRefs ?? {};
if (!isClickInImageArea(area, xWidget, yWidget, sizeWidget, localPos)) {
return originalOnMouseDown?.call(this, e, localPos, graphCanvas);
}
if (e.button !== 0) {
return originalOnMouseDown?.call(this, e, localPos, graphCanvas);
}
e.stopPropagation();
e.preventDefault();
this.isDraggingMarker = true;
updateMarkerPosition(area, localPos, xWidget, yWidget, sizeWidget);
return true;
};
nodeType.prototype.onMouseMove = function(e, localPos, graphCanvas) {
if (this.isDraggingMarker && !(e.buttons & 1)) {
this.isDraggingMarker = false;
}
if (this.isDraggingMarker) {
const area = this.imageArea;
const { x: xWidget, y: yWidget, size: sizeWidget } = this.widgetRefs ?? {};
if (area && xWidget && yWidget && sizeWidget) {
e.stopPropagation();
e.preventDefault();
updateMarkerPosition(area, localPos, xWidget, yWidget, sizeWidget);
return true;
}
}
const area = this.imageArea;
if (area && localPos) {
this.isMouseOverImage = isClickInImageArea(area, null, null, null, localPos);
}
return originalOnMouseMove?.call(this, e, localPos, graphCanvas);
};
nodeType.prototype.onMouseUp = function(e, localPos, graphCanvas) {
if (this.isDraggingMarker) {
this.isDraggingMarker = false;
e.stopPropagation();
e.preventDefault();
return true;
}
return originalOnMouseUp?.call(this, e, localPos, graphCanvas);
};
nodeType.prototype.onMouseLeave = function(e, graphCanvas) {
this.isDraggingMarker = false;
this.isMouseOverImage = false;
return originalOnMouseLeave?.call(this, e, graphCanvas);
};
};
const isClickInImageArea = (area, xWidget, yWidget, sizeWidget, localPos) => {
if (!area) return false;
return localPos[0] >= area.x &&
localPos[0] <= area.x + area.width &&
localPos[1] >= area.y &&
localPos[1] <= area.y + area.height;
};
const updateMarkerPosition = (area, localPos, xWidget, yWidget, sizeWidget) => {
const relativeX = localPos[0] - area.x;
const relativeY = localPos[1] - area.y;
const markerSize = sizeWidget.value * area.displayScale;
let markerX = relativeX - markerSize / 2;
let markerY = relativeY - markerSize / 2;
markerX = Utils.clamp(markerX, 0, area.width - markerSize);
markerY = Utils.clamp(markerY, 0, area.height - markerSize);
const origX = Math.round(markerX / area.displayScale);
const origY = Math.round(markerY / area.displayScale);
xWidget.value = origX;
yWidget.value = origY;
xWidget.callback?.(origX);
yWidget.callback?.(origY);
};
const adjustSizeForNode = (node, delta) => {
const sizeWidget = node.widgetRefs?.size;
if (!sizeWidget) return;
const bounds = {
w: node.previewData?.original_width,
h: node.previewData?.original_height
};
if (!bounds.w || !bounds.h) return;
const maxSize = Math.min(CONSTANTS.DEFAULT_VALUES.maxSize, bounds.w, bounds.h);
const newSize = Utils.clamp(sizeWidget.value + delta, CONSTANTS.DEFAULT_VALUES.minSize, maxSize);
sizeWidget.value = newSize;
sizeWidget.callback?.(newSize);
app.graph.setDirtyCanvas(true, true);
};
const setupUpdatePreview = (nodeType) => {
nodeType.prototype.updatePreview = function(imageInfo) {
if (!this.previewWidget) {
console.warn("Preview widget not found on node");
return;
}
this.previewData = {
scale: imageInfo.scale ?? 1,
original_width: imageInfo.original_width,
original_height: imageInfo.original_height
};
const url = Utils.constructImageURL(imageInfo.filename);
console.log("Loading image from:", url);
console.log("Preview data:", this.previewData);
const img = new Image();
img.onload = () => {
this.previewWidget.previewImage = img;
adjustNodeSize(this, img);
refreshCanvas(this);
};
img.onerror = (e) => {
console.error("Failed to load preview image:", url, e);
this.previewWidget.previewImage = null;
refreshCanvas(this);
};
img.src = url;
if (imageInfo.maskFilename) {
const maskUrl = Utils.constructImageURL(imageInfo.maskFilename);
console.log("Loading mask from:", maskUrl);
const maskImg = new Image();
maskImg.onload = () => {
this.previewWidget.previewMask = maskImg;
console.log("Mask loaded successfully");
refreshCanvas(this);
};
maskImg.onerror = (e) => {
console.error("Failed to load mask image:", maskUrl, e);
this.previewWidget.previewMask = null;
refreshCanvas(this);
};
maskImg.src = maskUrl;
} else {
this.previewWidget.previewMask = null;
}
};
};
const adjustNodeSize = (node, img) => {
if (!img?.naturalWidth || !img?.naturalHeight) return;
if (node.defaultSize &&
(node.size[0] !== node.defaultSize[0] || node.size[1] !== node.defaultSize[1])) {
return;
}
const widgetWidth = node.size[0] - CONSTANTS.MARGIN * 2;
const headerHeight = node.widgets_start_y ?? 0;
const totalHeight = headerHeight + 512 + CONSTANTS.MARGIN * 3;
const newHeight = Math.max(totalHeight, CONSTANTS.MIN_NODE_SIZE);
if (Math.abs(node.size[1] - newHeight) > 2) {
node.setSize([node.size[0], newHeight]);
node.graph?.setDirtyCanvas(true, true);
}
};
const refreshCanvas = (node) => {
node?.graph?.setDirtyCanvas(true, true);
};
const setupNodeCreation = (nodeType) => {
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const result = originalOnNodeCreated?.apply(this, arguments);
this.widgetRefs = {
x: this.widgets.find(w => w.name === "x"),
y: this.widgets.find(w => w.name === "y"),
size: this.widgets.find(w => w.name === "size"),
color: this.widgets.find(w => w.name === "color"),
show_mask: this.widgets.find(w => w.name === "show_mask")
};
this.previewWidget = this.addCustomWidget(createPreviewWidget(this));
this.previewData = {
scale: 1,
original_width: 0,
original_height: 0
};
this.setSize([
Math.max(this.size[0], CONSTANTS.MIN_NODE_SIZE),
Math.max(this.size[1], CONSTANTS.MIN_NODE_SIZE)
]);
this.defaultSize = [...this.size];
setupWidgetConstraints(this);
this.isMouseOverImage = false;
return result;
};
};
app.registerExtension({
name: CONSTANTS.EXTENSION_NAME,
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== CONSTANTS.NODE_NAME) return;
setupNodeCreation(nodeType);
setupMouseHandler(nodeType);
setupUpdatePreview(nodeType);
const originalOnRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function() {
return originalOnRemoved?.apply(this, arguments);
};
},
commands: [
{
id: "ImageCropLoop.increaseSize",
label: "Increase Marker Size",
function: () => {
const node = app.graph._nodes.find(n =>
n.type === CONSTANTS.NODE_NAME && n.isMouseOverImage
);
node && adjustSizeForNode(node, CONSTANTS.SIZE_INCREMENT);
}
},
{
id: "ImageCropLoop.decreaseSize",
label: "Decrease Marker Size",
function: () => {
const node = app.graph._nodes.find(n =>
n.type === CONSTANTS.NODE_NAME && n.isMouseOverImage
);
node && adjustSizeForNode(node, -CONSTANTS.SIZE_INCREMENT);
}
}
],
keybindings: [
{
combo: { key: "PageUp" },
commandId: "ImageCropLoop.increaseSize"
},
{
combo: { key: "PageDown" },
commandId: "ImageCropLoop.decreaseSize"
}
]
});