Files
ycyy-ComfyUI-YCYY-API/web/js/image_compare.js
T
2026-08-21 10:07:00 +08:00

439 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "YCYY_Image_Compare";
const MIN_WIDTH = 320;
const MIN_HEIGHT = 320;
const PADDING = 10;
const FOOTER_HEIGHT = 30;
const EMPTY_IMAGE = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==";
const NATIVE_PREVIEW_WIDGET = "$$canvas-image-preview";
const LOCALE_SETTING = "Comfy.Locale";
const MESSAGES = {
en: {
empty: "Connect images and run the workflow",
},
zh: {
empty: "连接图像并运行工作流",
},
};
let currentLocale = "en";
function normalizeLocale(locale) {
const value = String(locale || "en").toLowerCase();
return value.startsWith("zh") ? "zh" : "en";
}
function translate(key) {
const locale = normalizeLocale(currentLocale);
return MESSAGES[locale]?.[key] ?? MESSAGES.en[key] ?? key;
}
function isTargetNode(node) {
const type = node?.constructor?.comfyClass || node?.comfyClass || node?.type;
return type === NODE_NAME;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function pointInRect(pos, rect) {
return Boolean(rect)
&& pos[0] >= rect[0]
&& pos[0] <= rect[0] + rect[2]
&& pos[1] >= rect[1]
&& pos[1] <= rect[1] + rect[3];
}
function requestDraw(node, foreground = false) {
node?._ycyyCompareWidget?.triggerDraw?.();
app.graph?.setDirtyCanvas?.(true, foreground);
}
function redrawCompareNodes() {
for (const node of app.graph?._nodes || []) {
if (isTargetNode(node)) requestDraw(node, true);
}
}
function handleLocaleChange(event) {
currentLocale = event.detail?.value || "en";
redrawCompareNodes();
}
function imageRefs(value) {
return Array.isArray(value) ? value.filter((ref) => ref?.filename) : [];
}
function imageRefsFromMessage(message, key, seen = new Set()) {
if (!message || typeof message !== "object" || seen.has(message)) return [];
seen.add(message);
const direct = imageRefs(message[key]);
if (direct.length) return direct;
for (const value of Object.values(message)) {
const nested = imageRefsFromMessage(value, key, seen);
if (nested.length) return nested;
}
return [];
}
function imageKey(ref) {
return `${ref?.type || ""}/${ref?.subfolder || ""}/${ref?.filename || ""}`;
}
function imageUrl(ref) {
return api.apiURL(`/view?${new URLSearchParams(ref || {}).toString()}`);
}
function releaseImage(img) {
if (!img) return;
img.onload = null;
img.onerror = null;
try {
img.src = EMPTY_IMAGE;
} catch (_) {
// The browser may reject changing the source while an image is decoding.
}
}
function suppressNativePreview(node) {
if (!node) return;
node.preview = undefined;
const index = node.widgets?.findIndex((widget) => widget?.name === NATIVE_PREVIEW_WIDGET) ?? -1;
if (index >= 0) {
node.widgets[index].onRemove?.();
node.widgets.splice(index, 1);
}
node.imgs = undefined;
node.images = undefined;
}
function loadImage(node, side, ref) {
node._ycyyCompareImages ||= {};
const previous = node._ycyyCompareImages[side];
const key = ref?.filename ? imageKey(ref) : "";
if (!key) {
node._ycyyCompareImages[side] = null;
if (previous?.img) releaseImage(previous.img);
requestDraw(node, true);
return;
}
if (previous?.key === key) return;
const img = new Image();
const entry = { key, ref: { ...ref }, img };
node._ycyyCompareImages[side] = entry;
img.onload = () => {
if (node._ycyyCompareImages?.[side] !== entry) return;
requestDraw(node, true);
};
img.onerror = () => {
if (node._ycyyCompareImages?.[side] === entry) {
node._ycyyCompareImages[side] = null;
}
requestDraw(node, true);
};
img.src = imageUrl(ref);
if (previous?.img && previous.img !== img) releaseImage(previous.img);
}
function refAt(refs, index) {
if (!refs?.length) return null;
return refs[Math.min(index, refs.length - 1)];
}
function batchLength(node) {
const refs = node?._ycyyCompareRefs || {};
return Math.max(refs.a?.length || 0, refs.b?.length || 0);
}
function selectBatch(node, index) {
const total = batchLength(node);
if (!total) return;
node._ycyyCompareIndex = clamp(index, 0, total - 1);
loadImage(node, "a", refAt(node._ycyyCompareRefs?.a, node._ycyyCompareIndex));
loadImage(node, "b", refAt(node._ycyyCompareRefs?.b, node._ycyyCompareIndex));
requestDraw(node, true);
}
function receiveImages(node, message) {
const a = imageRefsFromMessage(message, "a_images");
const b = imageRefsFromMessage(message, "b_images");
node._ycyyCompareRefs = {
a: a.map((ref) => ({ ...ref })),
b: b.map((ref) => ({ ...ref })),
};
if (!a.length && !b.length) {
loadImage(node, "a", null);
loadImage(node, "b", null);
requestDraw(node, true);
return;
}
selectBatch(node, 0);
}
function fitRect(img, rect) {
const width = img?.naturalWidth || 0;
const height = img?.naturalHeight || 0;
if (!width || !height) return null;
const scale = Math.min(rect[2] / width, rect[3] / height);
const fittedWidth = Math.max(1, width * scale);
const fittedHeight = Math.max(1, height * scale);
return [
rect[0] + (rect[2] - fittedWidth) / 2,
rect[1] + (rect[3] - fittedHeight) / 2,
fittedWidth,
fittedHeight,
];
}
function drawImageContained(ctx, img, rect) {
const fitted = fitRect(img, rect);
if (fitted) ctx.drawImage(img, ...fitted);
return fitted;
}
function drawBadge(ctx, text, x, y, align = "left") {
ctx.font = "12px sans-serif";
const width = ctx.measureText(text).width + 14;
const left = align === "right" ? x - width : x;
ctx.fillStyle = "rgba(0, 0, 0, 0.68)";
ctx.beginPath();
ctx.roundRect?.(left, y, width, 22, 5);
if (!ctx.roundRect) ctx.rect(left, y, width, 22);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, left + width / 2, y + 11);
return [left, y, width, 22];
}
class CompareWidget {
constructor(node) {
this.type = "custom";
this.name = "ycyy_image_compare";
this.value = "";
this.options = {};
this.node = node;
this.rect = [0, 0, MIN_WIDTH, MIN_HEIGHT];
this.imageRect = null;
this.pageRect = null;
this.dragging = false;
}
computeSize(width) {
return [Math.max(MIN_WIDTH, width), MIN_HEIGHT];
}
normalizePosition(pos) {
if (pointInRect(pos, this.rect)) return pos;
return [pos[0] + this.rect[0], pos[1] + this.rect[1]];
}
setSplit(pos) {
const rect = this.imageRect || this.rect;
if (!rect[2]) return;
this.node._ycyyCompareSplit = clamp((pos[0] - rect[0]) / rect[2] * 100, 0, 100);
requestDraw(this.node, false);
}
mouse(event, rawPos) {
const pos = this.normalizePosition(rawPos);
const type = String(event?.type || "");
if (type.includes("up") || type.includes("cancel")) {
const wasDragging = this.dragging;
this.dragging = false;
return wasDragging;
}
if (type.includes("down") && event?.button === 0 && pointInRect(pos, this.pageRect)) {
const total = batchLength(this.node);
if (total > 1) selectBatch(this.node, (this.node._ycyyCompareIndex + 1) % total);
return true;
}
if (type.includes("down") && event?.button === 0 && pointInRect(pos, this.rect)) {
this.dragging = true;
this.setSplit(pos);
return true;
}
if (type.includes("move") && this.dragging) {
if (!((event?.buttons ?? 1) & 1)) {
this.dragging = false;
return false;
}
this.setSplit(pos);
return true;
}
return false;
}
draw(ctx, node, width, y, widgetHeight) {
// Node 2.0 renders legacy widgets inside a separate canvas whose width
// is independent from the LiteGraph node's stored size.
const nodeWidth = Number(width) || node.size?.[0] || MIN_WIDTH;
const isolatedCanvasHeight = Number(widgetHeight) >= 100
? Number(widgetHeight)
: Number(node.canvasHeight) >= 100
? Number(node.canvasHeight)
: 0;
const nodeHeight = isolatedCanvasHeight || node.size?.[1] || MIN_HEIGHT;
this.rect = [
PADDING,
y + PADDING,
Math.max(1, nodeWidth - PADDING * 2),
Math.max(1, nodeHeight - y - PADDING * 2),
];
const images = node._ycyyCompareImages || {};
const a = images.a?.img;
const b = images.b?.img;
const hasA = Boolean(a?.naturalWidth);
const hasB = Boolean(b?.naturalWidth);
ctx.save();
ctx.fillStyle = "#101010";
ctx.fillRect(...this.rect);
if (!hasA && !hasB) {
ctx.fillStyle = "#ccc";
ctx.font = "13px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(
translate("empty"),
this.rect[0] + this.rect[2] / 2,
this.rect[1] + this.rect[3] / 2,
);
ctx.restore();
return;
}
const imageArea = [this.rect[0], this.rect[1], this.rect[2], Math.max(1, this.rect[3] - FOOTER_HEIGHT)];
const baseRect = fitRect(a || b, imageArea) || imageArea;
this.imageRect = baseRect;
const splitX = baseRect[0] + baseRect[2] * (node._ycyyCompareSplit ?? 50) / 100;
if (hasB) drawImageContained(ctx, b, baseRect);
if (hasA && !hasB) {
drawImageContained(ctx, a, baseRect);
} else if (hasA) {
ctx.save();
ctx.beginPath();
ctx.rect(baseRect[0], baseRect[1], Math.max(0, splitX - baseRect[0]), baseRect[3]);
ctx.clip();
drawImageContained(ctx, a, baseRect);
ctx.restore();
}
if (hasA && hasB) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.8)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(splitX, baseRect[1]);
ctx.lineTo(splitX, baseRect[1] + baseRect[3]);
ctx.stroke();
ctx.fillStyle = "rgba(255, 255, 255, 0.75)";
ctx.beginPath();
ctx.arc(splitX, baseRect[1] + baseRect[3] / 2, 8, 0, Math.PI * 2);
ctx.fill();
}
const footerY = this.rect[1] + this.rect[3] - FOOTER_HEIGHT + 4;
if (hasA) drawBadge(ctx, `A · ${a.naturalWidth} × ${a.naturalHeight}`, this.rect[0] + 8, footerY);
if (hasB) drawBadge(ctx, `B · ${b.naturalWidth} × ${b.naturalHeight}`, this.rect[0] + this.rect[2] - 8, footerY, "right");
const total = batchLength(node);
if (total > 1) {
const text = `${(node._ycyyCompareIndex || 0) + 1}/${total}`;
ctx.font = "12px sans-serif";
const badgeWidth = ctx.measureText(text).width + 14;
this.pageRect = drawBadge(ctx, text, this.rect[0] + (this.rect[2] - badgeWidth) / 2, footerY);
} else {
this.pageRect = null;
}
ctx.restore();
}
}
function activateNode(node) {
if (!isTargetNode(node)) return;
suppressNativePreview(node);
if (!node._ycyyCompareWidget && typeof node.addCustomWidget === "function") {
node._ycyyCompareSplit ??= 50;
node._ycyyCompareIndex ??= 0;
node._ycyyCompareImages ||= {};
node._ycyyCompareWidget = node.addCustomWidget(new CompareWidget(node));
}
node.size ||= [MIN_WIDTH, MIN_HEIGHT];
node.size[0] = Math.max(node.size[0] || 0, MIN_WIDTH);
node.size[1] = Math.max(node.size[1] || 0, MIN_HEIGHT);
requestDraw(node, true);
}
function disposeNode(node) {
for (const entry of Object.values(node?._ycyyCompareImages || {})) {
releaseImage(entry?.img);
}
node._ycyyCompareImages = {};
node._ycyyCompareRefs = {};
node._ycyyCompareWidget = null;
}
app.registerExtension({
name: "YCYY.Image.Compare",
async setup() {
currentLocale = app.ui?.settings?.getSettingValue?.(LOCALE_SETTING) || "en";
app.ui?.settings?.addEventListener?.(`${LOCALE_SETTING}.change`, handleLocaleChange);
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) return;
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onCreated?.apply(this, arguments);
activateNode(this);
return result;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = onConfigure?.apply(this, arguments);
setTimeout(() => activateNode(this), 0);
return result;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
activateNode(this);
receiveImages(this, message);
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
disposeNode(this);
return onRemoved?.apply(this, arguments);
};
const onDrawBackground = nodeType.prototype.onDrawBackground;
nodeType.prototype.onDrawBackground = function () {
suppressNativePreview(this);
return onDrawBackground?.apply(this, arguments);
};
},
async nodeCreated(node) {
activateNode(node);
},
});