439 lines
14 KiB
JavaScript
439 lines
14 KiB
JavaScript
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);
|
||
},
|
||
});
|