574 lines
18 KiB
JavaScript
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"
|
|
}
|
|
]
|
|
}); |