Both replace a single node we were pulling an entire pack in for. RMAddLabel matches KJNodes AddLabel widget-for-widget, with two fixes: left/right now work at any aspect ratio (the original rotated on the width/channel axes and returned a [B,C,H,W] tensor that could only concatenate when the image was square), and fonts are read from the system font directory as well as the pack, so a clean install has 361 usable fonts instead of none. RMImageComparer is a self-contained reimplementation of rgthree Image Comparer. The Python half subclasses PreviewImage; the slider is drawn on the node canvas in js/rm_image_comparer.js rather than through rgthree base-node and widget framework. The batch index widget is added in JS, not Python, so stepping through a batch does not invalidate the cache and re-run the graph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uM7Ru1JYn48n6c5Rzwd99
274 lines
9.7 KiB
JavaScript
274 lines
9.7 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { api } from "../../scripts/api.js";
|
|
|
|
/**
|
|
* RM Image Comparer.
|
|
*
|
|
* A/B wipe between two images. Written from scratch rather than lifted from rgthree, whose
|
|
* version depends on its own base-node and widget framework — importing that would mean
|
|
* installing most of the pack to get one node.
|
|
*
|
|
* Drawing happens in onDrawBackground against the node's own canvas rather than through DOM
|
|
* overlays, so the comparison pans and zooms with the graph instead of floating above it.
|
|
*
|
|
* Interaction: hovering wipes, clicking locks the slider so you can look away without losing
|
|
* the position, and clicking again releases it. The locked state is serialised with the
|
|
* workflow because a comparison you set up deliberately should survive a reload.
|
|
*/
|
|
|
|
const NODE_NAME = "RMImageComparer";
|
|
|
|
/** Gap under the widgets before the image area starts. */
|
|
const TOP_PAD = 4;
|
|
const HANDLE_W = 2;
|
|
const HANDLE_GRAB = 12;
|
|
|
|
function imageURL(entry) {
|
|
if (!entry) return null;
|
|
const params = new URLSearchParams({
|
|
filename: entry.filename ?? "",
|
|
subfolder: entry.subfolder ?? "",
|
|
type: entry.type ?? "temp",
|
|
});
|
|
// Previews reuse filenames across runs; without a cache-buster the browser happily shows
|
|
// the previous execution's image.
|
|
params.set("rand", String(Math.random()));
|
|
return api.apiURL(`/view?${params.toString()}`);
|
|
}
|
|
|
|
/** Fit `natural` inside `box` preserving aspect ratio, centred. */
|
|
function fitRect(naturalW, naturalH, boxX, boxY, boxW, boxH) {
|
|
if (!naturalW || !naturalH || boxW <= 0 || boxH <= 0) return null;
|
|
const scale = Math.min(boxW / naturalW, boxH / naturalH);
|
|
const w = naturalW * scale;
|
|
const h = naturalH * scale;
|
|
return { x: boxX + (boxW - w) / 2, y: boxY + (boxH - h) / 2, w, h };
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "RMAutomation.ImageComparer",
|
|
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== NODE_NAME) return;
|
|
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
const r = onNodeCreated?.apply(this, arguments);
|
|
|
|
this.rmCompare = {
|
|
a: [], // loaded HTMLImageElements for side A
|
|
b: [],
|
|
index: 0, // which frame of the batch to show
|
|
slider: 0.5, // 0..1 across the drawn image rect
|
|
locked: false,
|
|
rect: null, // last drawn image rect, for hit-testing
|
|
};
|
|
this.rmIndexWidget = null;
|
|
|
|
if (this.size[0] < 240) this.size[0] = 240;
|
|
if (this.size[1] < 260) this.size[1] = 260;
|
|
|
|
return r;
|
|
};
|
|
|
|
/** Load previews returned by the Python half. */
|
|
nodeType.prototype.rmSetImages = function (aEntries, bEntries) {
|
|
const state = this.rmCompare;
|
|
const load = (entries) =>
|
|
(entries ?? []).map((entry) => {
|
|
const img = new Image();
|
|
// Redraw as each image arrives; otherwise the node stays blank until the whole
|
|
// batch has decoded.
|
|
img.onload = () => this.setDirtyCanvas(true, true);
|
|
img.src = imageURL(entry);
|
|
return img;
|
|
});
|
|
|
|
state.a = load(aEntries);
|
|
state.b = load(bEntries);
|
|
state.index = 0;
|
|
|
|
this.rmSyncIndexWidget();
|
|
this.setDirtyCanvas(true, true);
|
|
};
|
|
|
|
/**
|
|
* Show an index widget only while there is a batch to step through. Adding it in JS
|
|
* rather than declaring it in Python matters: a Python widget is an input, so changing
|
|
* it would invalidate the cache and re-run the graph just to look at another frame.
|
|
*/
|
|
nodeType.prototype.rmSyncIndexWidget = function () {
|
|
const state = this.rmCompare;
|
|
const count = Math.max(state.a.length, state.b.length);
|
|
|
|
if (count <= 1) {
|
|
if (this.rmIndexWidget) {
|
|
const i = this.widgets.indexOf(this.rmIndexWidget);
|
|
if (i > -1) this.widgets.splice(i, 1);
|
|
this.rmIndexWidget = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!this.rmIndexWidget) {
|
|
this.rmIndexWidget = this.addWidget(
|
|
"number",
|
|
"index",
|
|
0,
|
|
(v) => {
|
|
const max = Math.max(this.rmCompare.a.length, this.rmCompare.b.length) - 1;
|
|
this.rmCompare.index = Math.max(0, Math.min(max, Math.round(v)));
|
|
this.rmIndexWidget.value = this.rmCompare.index;
|
|
this.setDirtyCanvas(true, true);
|
|
},
|
|
{ min: 0, max: count - 1, step: 10, precision: 0 },
|
|
);
|
|
// Widgets are not inputs; keep them out of the serialised prompt.
|
|
this.rmIndexWidget.serialize = false;
|
|
}
|
|
this.rmIndexWidget.options.max = count - 1;
|
|
this.rmCompare.index = Math.min(this.rmCompare.index, count - 1);
|
|
this.rmIndexWidget.value = this.rmCompare.index;
|
|
};
|
|
|
|
const onExecuted = nodeType.prototype.onExecuted;
|
|
nodeType.prototype.onExecuted = function (message) {
|
|
onExecuted?.apply(this, arguments);
|
|
this.rmSetImages(message?.a_images, message?.b_images);
|
|
};
|
|
|
|
const onDrawBackground = nodeType.prototype.onDrawBackground;
|
|
nodeType.prototype.onDrawBackground = function (ctx) {
|
|
onDrawBackground?.apply(this, arguments);
|
|
if (this.flags?.collapsed) return;
|
|
|
|
const state = this.rmCompare;
|
|
if (!state) return;
|
|
|
|
const top = this.widgets_start_y != null
|
|
? this.widgets_start_y + (this.widgets?.length ?? 0) * 24 + TOP_PAD
|
|
: TOP_PAD;
|
|
const boxX = 8;
|
|
const boxY = top;
|
|
const boxW = this.size[0] - 16;
|
|
const boxH = this.size[1] - top - 8;
|
|
if (boxW <= 0 || boxH <= 0) return;
|
|
|
|
const imgA = state.a[Math.min(state.index, state.a.length - 1)];
|
|
const imgB = state.b[Math.min(state.index, state.b.length - 1)];
|
|
const ready = (img) => img && img.naturalWidth > 0;
|
|
|
|
if (!ready(imgA) && !ready(imgB)) {
|
|
state.rect = null;
|
|
ctx.save();
|
|
ctx.fillStyle = "#666";
|
|
ctx.font = "12px sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText("Run the graph to compare", boxX + boxW / 2, boxY + boxH / 2);
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
// Size the frame from whichever side we have, so a single wired input still previews.
|
|
const ref = ready(imgA) ? imgA : imgB;
|
|
const rect = fitRect(ref.naturalWidth, ref.naturalHeight, boxX, boxY, boxW, boxH);
|
|
if (!rect) return;
|
|
state.rect = rect;
|
|
|
|
ctx.save();
|
|
|
|
if (ready(imgA)) ctx.drawImage(imgA, rect.x, rect.y, rect.w, rect.h);
|
|
|
|
// Only wipe when there are genuinely two sides; with one image the slider would just
|
|
// erase half the preview for no reason.
|
|
if (ready(imgB) && ready(imgA)) {
|
|
const splitX = rect.x + rect.w * state.slider;
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(splitX, rect.y, rect.x + rect.w - splitX, rect.h);
|
|
ctx.clip();
|
|
ctx.drawImage(imgB, rect.x, rect.y, rect.w, rect.h);
|
|
ctx.restore();
|
|
|
|
ctx.fillStyle = state.locked ? "#ffb300" : "#ffffff";
|
|
ctx.fillRect(splitX - HANDLE_W / 2, rect.y, HANDLE_W, rect.h);
|
|
} else if (ready(imgB)) {
|
|
ctx.drawImage(imgB, rect.x, rect.y, rect.w, rect.h);
|
|
}
|
|
|
|
ctx.restore();
|
|
};
|
|
|
|
/** Map a node-local point onto the slider track. Returns null when outside the image. */
|
|
nodeType.prototype.rmSliderFromPos = function (pos) {
|
|
const rect = this.rmCompare?.rect;
|
|
if (!rect) return null;
|
|
const [x, y] = pos;
|
|
if (y < rect.y || y > rect.y + rect.h) return null;
|
|
return Math.max(0, Math.min(1, (x - rect.x) / rect.w));
|
|
};
|
|
|
|
const onMouseMove = nodeType.prototype.onMouseMove;
|
|
nodeType.prototype.onMouseMove = function (event, pos, canvas) {
|
|
onMouseMove?.apply(this, arguments);
|
|
const state = this.rmCompare;
|
|
if (!state || state.locked) return;
|
|
const t = this.rmSliderFromPos(pos);
|
|
if (t == null) return;
|
|
state.slider = t;
|
|
this.setDirtyCanvas(true, true);
|
|
};
|
|
|
|
const onMouseDown = nodeType.prototype.onMouseDown;
|
|
nodeType.prototype.onMouseDown = function (event, pos, canvas) {
|
|
const handled = onMouseDown?.apply(this, arguments);
|
|
if (handled) return handled;
|
|
|
|
const state = this.rmCompare;
|
|
if (!state?.rect) return false;
|
|
const t = this.rmSliderFromPos(pos);
|
|
if (t == null) return false;
|
|
|
|
// Clicking near a locked handle grabs it; clicking anywhere else toggles the lock.
|
|
const splitX = state.rect.x + state.rect.w * state.slider;
|
|
const nearHandle = Math.abs(pos[0] - splitX) <= HANDLE_GRAB;
|
|
|
|
if (state.locked && nearHandle) {
|
|
state.slider = t;
|
|
} else {
|
|
state.locked = !state.locked;
|
|
if (state.locked) state.slider = t;
|
|
}
|
|
this.setDirtyCanvas(true, true);
|
|
return true;
|
|
};
|
|
|
|
const onMouseLeave = nodeType.prototype.onMouseLeave;
|
|
nodeType.prototype.onMouseLeave = function () {
|
|
onMouseLeave?.apply(this, arguments);
|
|
const state = this.rmCompare;
|
|
if (!state || state.locked) return;
|
|
// Park it centred so the node reads as a comparison at a glance when not hovered.
|
|
state.slider = 0.5;
|
|
this.setDirtyCanvas(true, true);
|
|
};
|
|
|
|
const onSerialize = nodeType.prototype.onSerialize;
|
|
nodeType.prototype.onSerialize = function (info) {
|
|
onSerialize?.apply(this, arguments);
|
|
const state = this.rmCompare;
|
|
if (!state) return;
|
|
info.rm_compare = { slider: state.slider, locked: state.locked };
|
|
};
|
|
|
|
const onConfigure = nodeType.prototype.onConfigure;
|
|
nodeType.prototype.onConfigure = function (info) {
|
|
onConfigure?.apply(this, arguments);
|
|
const saved = info?.rm_compare;
|
|
if (!saved || !this.rmCompare) return;
|
|
if (typeof saved.slider === "number") this.rmCompare.slider = saved.slider;
|
|
this.rmCompare.locked = !!saved.locked;
|
|
};
|
|
},
|
|
});
|