Files
Moser9815andClaude Opus 5 b7177c23d2 RMSaveLineage: image inputs grow as you connect them
Connecting the last image slot appends a fresh empty one, so a chain can be extended
without pressing a button. Its sibling RMMakeImageBatch uses an explicit "Add Image
Input" button; here slot order IS chain order, so the next slot should already be waiting.

Only the tail is managed - there is always exactly one trailing empty slot. Middle gaps
are deliberately left alone: the Python side sorts by slot number and skips unwired slots,
so 1,3,4 is still the chain 1->3->4, and compacting would silently re-point every parent
link in the lineage record.

Slot bookkeeping is deferred a frame and made idempotent, because LiteGraph fires
onConnectionsChange once per restored link during a workflow load - it must converge
rather than accumulate slots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_012uM7Ru1JYn48n6c5Rzwd99
2026-08-24 17:05:59 -04:00

125 lines
4.4 KiB
JavaScript

import { app } from "../../scripts/app.js";
/**
* RM Save Lineage - self-growing image inputs.
*
* Connect something to the last image slot and a fresh empty one appears below it, so the
* chain can be extended without ever pressing a button. Its sibling RMMakeImageBatch uses
* an explicit "Add Image Input" button; here the slot order IS the chain order, so you are
* always building a sequence and the next slot should already be waiting for you.
*
* Middle gaps are deliberately left alone. Disconnecting image_2 of four leaves an empty
* slot in place rather than renumbering, because the Python side sorts by slot number and
* skips unwired slots - so 1, 3, 4 is still the chain 1 -> 3 -> 4. Compacting would silently
* re-point every parent link in the lineage record.
*
* Only the tail is managed: there is always exactly one trailing empty slot.
*/
const NODE_NAME = "RMSaveLineage";
const PREFIX = "image_";
/** Image input slots with their parsed number, ordered by that number. */
function imageSlots(node) {
const slots = [];
(node.inputs ?? []).forEach((input, index) => {
if (!input?.name?.startsWith(PREFIX)) return;
const n = Number(input.name.slice(PREFIX.length));
if (Number.isInteger(n) && n > 0) slots.push({ index, n, input });
});
slots.sort((a, b) => a.n - b.n);
return slots;
}
const isConnected = (slot) => slot.input.link != null;
/**
* Guarantee exactly one trailing empty image slot.
*
* Idempotent on purpose: LiteGraph fires onConnectionsChange once per restored link while
* loading a workflow, so this runs many times in a row and must converge, not accumulate.
*/
function ensureTrailingSlot(node) {
let slots = imageSlots(node);
// Nothing at all (shouldn't happen - Python declares image_1 - but a hand-edited
// workflow can arrive without it).
if (slots.length === 0) {
node.addInput(`${PREFIX}1`, "IMAGE");
resize(node);
return;
}
// Trailing run of unconnected slots, counted from the end.
let trailing = 0;
for (let i = slots.length - 1; i >= 0; i--) {
if (isConnected(slots[i])) break;
trailing++;
}
if (trailing === 0) {
const next = slots[slots.length - 1].n + 1;
node.addInput(`${PREFIX}${next}`, "IMAGE");
resize(node);
return;
}
if (trailing > 1) {
// Remove extras from the end so earlier indices stay valid as we splice.
// Never drop image_1: the node should always offer at least one slot.
const removable = slots.slice(slots.length - trailing + 1).filter((s) => s.n !== 1);
for (let i = removable.length - 1; i >= 0; i--) {
const idx = node.inputs.findIndex((inp) => inp.name === removable[i].input.name);
if (idx > -1) node.removeInput(idx);
}
resize(node);
}
}
function resize(node) {
const size = node.computeSize();
node.setSize([Math.max(node.size[0], size[0]), size[1]]);
node.setDirtyCanvas(true, true);
}
app.registerExtension({
name: "RMAutomation.SaveLineage",
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);
// Defer: inputs declared by the node definition are not attached yet at this point.
requestAnimationFrame(() => ensureTrailingSlot(this));
return r;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = onConfigure?.apply(this, arguments);
// After a workflow loads, links are attached but our slot bookkeeping has not run.
requestAnimationFrame(() => ensureTrailingSlot(this));
return r;
};
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function (type, slotIndex, connected, linkInfo, ioSlot) {
const r = onConnectionsChange?.apply(this, arguments);
// LiteGraph.INPUT === 1. Compared numerically because the constant is not always
// in scope in every frontend build.
if (type !== 1) return r;
if (!ioSlot?.name?.startsWith(PREFIX) && !this.inputs?.[slotIndex]?.name?.startsWith(PREFIX)) {
return r;
}
// Deferred so LiteGraph has finished mutating this.inputs before we read it -
// on disconnect the link is still present during the callback.
requestAnimationFrame(() => ensureTrailingSlot(this));
return r;
};
},
});