feat(image-list)!: replace fixed Start extras with growable value_N slots
Distributed Image List Start no longer has reference_image, text, strength, seed or per-slot mode dropdowns. It now takes images, mask and up to 10 generic value_N inputs (IMAGE, MASK, STRING, INT, FLOAT, BOOLEAN) that grow via native V3 Autogrow, each with a matching output. - One value is shared by every image; one per image pairs in order; any other count fails before dispatch. Only images is split into frames. - Outputs are declared up front (image, mask, item_index, context, value_1..value_10). web/imageListStart.js hides unused value outputs, mirrors input types, and follows Autogrow compaction so downstream links keep their value. - Image-list protocol bumped to v2; older workers fail preflight. Breaking: saved workflows using the old Start inputs must recreate Start.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
// Distributed Image List Start declares value_1..value_10 outputs in Python so
|
||||
// saved links and prompt output indexes stay valid. ComfyUI grows the matching
|
||||
// `values.value_N` inputs natively (Autogrow) but has no growable outputs, so
|
||||
// this extension only presents them: it shows outputs for connected inputs,
|
||||
// copies each input's type onto its output, and moves output links when
|
||||
// Autogrow compacts inputs after a middle input is disconnected.
|
||||
// If this script fails, the node still runs; all outputs simply stay visible.
|
||||
|
||||
const START_CLASS = "DistributedImageListStart";
|
||||
const FIXED_OUTPUTS = 4; // image, mask, item_index, context
|
||||
const MAX_VALUES = 10;
|
||||
const INPUT_PREFIX = "values.value_";
|
||||
|
||||
function valueOrdinal(name) {
|
||||
if (typeof name !== "string" || !name.startsWith(INPUT_PREFIX)) return null;
|
||||
const ordinal = Number.parseInt(name.slice(INPUT_PREFIX.length), 10);
|
||||
return ordinal >= 1 && ordinal <= MAX_VALUES ? ordinal : null;
|
||||
}
|
||||
|
||||
function getLink(graph, id) {
|
||||
if (id == null || !graph) return undefined;
|
||||
if (typeof graph.getLink === "function") return graph.getLink(id) ?? undefined;
|
||||
const links = graph.links;
|
||||
return (typeof links?.get === "function" ? links.get(id) : links?.[id]) ?? undefined;
|
||||
}
|
||||
|
||||
function connectedValues(node) {
|
||||
const result = new Map();
|
||||
for (const input of node.inputs ?? []) {
|
||||
const ordinal = valueOrdinal(input.name);
|
||||
if (!ordinal) continue;
|
||||
const link = getLink(node.graph, input.link);
|
||||
if (!link) continue;
|
||||
const origin = node.graph.getNodeById?.(link.origin_id);
|
||||
const originType = origin?.outputs?.[link.origin_slot]?.type;
|
||||
const type = originType && originType !== "*" ? originType : (link.type ?? "*");
|
||||
result.set(ordinal, { type, key: `${link.origin_id}:${link.origin_slot}` });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function outputLinks(node, ordinal) {
|
||||
return node.outputs?.[FIXED_OUTPUTS + ordinal - 1]?.links ?? [];
|
||||
}
|
||||
|
||||
function typesCompatible(outputType, inputType) {
|
||||
if (!outputType || !inputType || outputType === "*" || inputType === "*") return true;
|
||||
const accepted = String(inputType).split(",");
|
||||
return String(outputType).split(",").some(type => accepted.includes(type));
|
||||
}
|
||||
|
||||
function moveOutputLinks(node, from, to) {
|
||||
const graph = node.graph;
|
||||
const targets = outputLinks(node, from)
|
||||
.map(id => getLink(graph, id))
|
||||
.filter(Boolean)
|
||||
.map(link => [graph.getNodeById?.(link.target_id), link.target_slot])
|
||||
.filter(([target]) => target);
|
||||
// The moved links carry the same value, so give the destination output its
|
||||
// type first; LiteGraph refuses connections to a mismatched output type.
|
||||
node.outputs[FIXED_OUTPUTS + to - 1].type = node.outputs[FIXED_OUTPUTS + from - 1].type;
|
||||
// Connecting a target input replaces its existing link from `from`.
|
||||
for (const [target, slot] of targets) node.connect(FIXED_OUTPUTS + to - 1, target, slot);
|
||||
}
|
||||
|
||||
function disconnectValueOutput(node, ordinal) {
|
||||
if (outputLinks(node, ordinal).length) node.disconnectOutput(FIXED_OUTPUTS + ordinal - 1);
|
||||
}
|
||||
|
||||
// Autogrow removes a disconnected middle input and shifts later input links
|
||||
// down by one. Shift the matching outputs' links the same way, and drop the
|
||||
// removed value's downstream links, so no consumer silently receives a
|
||||
// different value.
|
||||
function followCompactedInputs(node, current) {
|
||||
const previous = node._distributedValueSources;
|
||||
if (!previous) return;
|
||||
const now = [];
|
||||
for (const [ordinal, info] of current) now[ordinal - 1] = info.key;
|
||||
const before = previous;
|
||||
if (now.length !== before.length - 1) return;
|
||||
let removed = 0;
|
||||
while (removed < now.length && now[removed] === before[removed]) removed++;
|
||||
// Removing the last value shifts nothing; keep its links so a temporary
|
||||
// rewire does not silently drop downstream connections.
|
||||
if (removed >= now.length) return;
|
||||
for (let i = removed; i < now.length; i++) {
|
||||
if (now[i] !== before[i + 1]) return; // Not a single Autogrow compaction.
|
||||
}
|
||||
disconnectValueOutput(node, removed + 1);
|
||||
for (let i = removed + 1; i < before.length; i++) moveOutputLinks(node, i + 1, i);
|
||||
}
|
||||
|
||||
function syncImageListStartOutputs(node) {
|
||||
if (!node?.graph || !Array.isArray(node.outputs) || node.outputs.length < FIXED_OUTPUTS) return;
|
||||
const current = connectedValues(node);
|
||||
followCompactedInputs(node, current);
|
||||
const sources = [];
|
||||
for (const [ordinal, info] of current) sources[ordinal - 1] = info.key;
|
||||
node._distributedValueSources = Array.from(sources);
|
||||
|
||||
let visible = Math.max(0, ...current.keys());
|
||||
for (let ordinal = MAX_VALUES; ordinal > visible; ordinal--) {
|
||||
// Never drop a wired output; its links keep it visible until removed.
|
||||
if (outputLinks(node, ordinal).length) visible = ordinal;
|
||||
}
|
||||
const listShape = node.outputs[0]?.shape;
|
||||
while (node.outputs.length < FIXED_OUTPUTS + visible) {
|
||||
const ordinal = node.outputs.length - FIXED_OUTPUTS + 1;
|
||||
node.addOutput(`value_${ordinal}`, "*", listShape === undefined ? undefined : { shape: listShape });
|
||||
}
|
||||
while (node.outputs.length > FIXED_OUTPUTS + visible) node.removeOutput(node.outputs.length - 1);
|
||||
|
||||
for (let ordinal = 1; ordinal <= visible; ordinal++) {
|
||||
const output = node.outputs[FIXED_OUTPUTS + ordinal - 1];
|
||||
const type = current.get(ordinal)?.type ?? "*";
|
||||
output.name = `value_${ordinal}`;
|
||||
if (output.type !== type) {
|
||||
output.type = type;
|
||||
for (const id of [...(output.links ?? [])]) {
|
||||
const link = getLink(node.graph, id);
|
||||
if (!link) continue;
|
||||
const target = node.graph.getNodeById?.(link.target_id);
|
||||
const targetType = target?.inputs?.[link.target_slot]?.type;
|
||||
if (target && !typesCompatible(type, targetType)) target.disconnectInput(link.target_slot);
|
||||
else link.type = type;
|
||||
}
|
||||
}
|
||||
}
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
function scheduleSync(node) {
|
||||
if (node._distributedValueSyncPending) return;
|
||||
node._distributedValueSyncPending = true;
|
||||
const run = () => {
|
||||
node._distributedValueSyncPending = false;
|
||||
try {
|
||||
syncImageListStartOutputs(node);
|
||||
} catch (error) {
|
||||
console.error("[Distributed] Image List Start output sync failed:", error);
|
||||
}
|
||||
};
|
||||
// Run after Autogrow's own animation-frame input compaction.
|
||||
if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => setTimeout(run, 0));
|
||||
else setTimeout(run, 0);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Distributed.ImageListStartValues",
|
||||
nodeCreated(node) {
|
||||
if (node.comfyClass !== START_CLASS) return;
|
||||
const onConnectionsChange = node.onConnectionsChange;
|
||||
node.onConnectionsChange = function (...args) {
|
||||
const result = onConnectionsChange?.apply(this, args);
|
||||
scheduleSync(this);
|
||||
return result;
|
||||
};
|
||||
const onConfigure = node.onConfigure;
|
||||
node.onConfigure = function (...args) {
|
||||
const result = onConfigure?.apply(this, args);
|
||||
// A loaded graph is the source of truth for prior wiring.
|
||||
this._distributedValueSources = undefined;
|
||||
scheduleSync(this);
|
||||
return result;
|
||||
};
|
||||
scheduleSync(node);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../imageListStart.js", import.meta.url), "utf8")
|
||||
.replace(/^import .*;\r?\n/gm, "");
|
||||
|
||||
// Minimal LiteGraph-like graph: enough link bookkeeping to exercise output
|
||||
// add/remove, retyping, and link moves the extension performs.
|
||||
function setup() {
|
||||
let extension;
|
||||
const tasks = [];
|
||||
const links = new Map();
|
||||
let nextLink = 1;
|
||||
const nodes = new Map();
|
||||
const graph = {
|
||||
links,
|
||||
getLink: id => links.get(id),
|
||||
getNodeById: id => nodes.get(id),
|
||||
};
|
||||
const makeSource = (id, type) => {
|
||||
const node = { id, outputs: [{ type, links: [] }] };
|
||||
nodes.set(id, node);
|
||||
return node;
|
||||
};
|
||||
const makeTarget = (id, type) => {
|
||||
const node = {
|
||||
id, inputs: [{ type, link: null }],
|
||||
disconnectInput(slot) {
|
||||
const link = links.get(this.inputs[slot].link);
|
||||
if (!link) return;
|
||||
const origin = nodes.get(link.origin_id);
|
||||
origin.outputs[link.origin_slot].links =
|
||||
origin.outputs[link.origin_slot].links.filter(l => l !== link.id);
|
||||
links.delete(link.id);
|
||||
this.inputs[slot].link = null;
|
||||
},
|
||||
};
|
||||
nodes.set(id, node);
|
||||
return node;
|
||||
};
|
||||
const fixed = ["image", "mask", "item_index", "context"].map(name => ({ name, type: "IMAGE", links: [], shape: 6 }));
|
||||
const start = {
|
||||
id: 1, comfyClass: "DistributedImageListStart", graph,
|
||||
inputs: [{ name: "images", link: null }, { name: "mask", link: null }, { name: "values.value_1", link: null }],
|
||||
outputs: [...fixed, ...Array.from({ length: 10 }, (_, i) => ({ name: `value_${i + 1}`, type: "*", links: [] }))],
|
||||
addOutput(name, type, extra) { this.outputs.push({ name, type, links: [], ...(extra ?? {}) }); },
|
||||
removeOutput(slot) {
|
||||
if (this.outputs[slot].links.length) throw new Error("removed a wired output");
|
||||
this.outputs.splice(slot, 1);
|
||||
},
|
||||
connect(slot, target, targetSlot) {
|
||||
// LiteGraph refuses a link whose output type does not fit the input.
|
||||
const outType = this.outputs[slot].type;
|
||||
const inType = target.inputs[targetSlot].type;
|
||||
if (outType !== "*" && inType !== "*" && !String(inType).split(",").includes(outType)) return null;
|
||||
target.disconnectInput(targetSlot);
|
||||
const id = nextLink++;
|
||||
links.set(id, { id, origin_id: this.id, origin_slot: slot, target_id: target.id,
|
||||
target_slot: targetSlot, type: this.outputs[slot].type });
|
||||
this.outputs[slot].links.push(id);
|
||||
target.inputs[targetSlot].link = id;
|
||||
return links.get(id);
|
||||
},
|
||||
disconnectOutput(slot) {
|
||||
for (const id of [...this.outputs[slot].links]) {
|
||||
const link = links.get(id);
|
||||
nodes.get(link.target_id).disconnectInput(link.target_slot);
|
||||
}
|
||||
},
|
||||
setDirtyCanvas() {},
|
||||
};
|
||||
nodes.set(1, start);
|
||||
vm.runInNewContext(source, {
|
||||
app: { registerExtension: ext => { extension = ext; } },
|
||||
setTimeout: callback => { tasks.push(callback); },
|
||||
console,
|
||||
}, { filename: "imageListStart.js" });
|
||||
extension.nodeCreated(start);
|
||||
const flush = () => { while (tasks.length) tasks.shift()(); };
|
||||
// Wire a source output into Start's value input, as Autogrow would.
|
||||
const connectValue = (ordinal, sourceNode) => {
|
||||
const name = `values.value_${ordinal}`;
|
||||
let input = start.inputs.find(i => i.name === name);
|
||||
if (!input) { input = { name, link: null }; start.inputs.push(input); }
|
||||
const id = nextLink++;
|
||||
links.set(id, { id, origin_id: sourceNode.id, origin_slot: 0, target_id: 1,
|
||||
target_slot: start.inputs.indexOf(input), type: sourceNode.outputs[0].type });
|
||||
sourceNode.outputs[0].links.push(id);
|
||||
input.link = id;
|
||||
if (!start.inputs.some(i => i.name === `values.value_${ordinal + 1}`)) {
|
||||
start.inputs.push({ name: `values.value_${ordinal + 1}`, link: null });
|
||||
}
|
||||
start.onConnectionsChange(1, start.inputs.indexOf(input), true);
|
||||
flush();
|
||||
};
|
||||
// Mimic Autogrow's compaction after disconnecting a middle input.
|
||||
const disconnectValueAndCompact = ordinal => {
|
||||
const valueInputs = start.inputs.filter(i => i.name.startsWith("values."));
|
||||
const linksBefore = valueInputs.map(i => i.link);
|
||||
links.delete(linksBefore[ordinal - 1]);
|
||||
const remaining = linksBefore.filter((l, i) => i !== ordinal - 1 && l != null);
|
||||
start.inputs = start.inputs.filter(i => !i.name.startsWith("values."));
|
||||
remaining.forEach((l, i) => start.inputs.push({ name: `values.value_${i + 1}`, link: l }));
|
||||
start.inputs.push({ name: `values.value_${remaining.length + 1}`, link: null });
|
||||
start.onConnectionsChange(1, 0, false);
|
||||
flush();
|
||||
};
|
||||
flush();
|
||||
return { start, makeSource, makeTarget, connectValue, disconnectValueAndCompact, links, flush };
|
||||
}
|
||||
|
||||
const valueOutputs = start => start.outputs.slice(4).map(o => `${o.name}:${o.type}`);
|
||||
|
||||
describe("Distributed Image List Start value outputs", () => {
|
||||
it("shows no value outputs until a value input is connected", () => {
|
||||
const h = setup();
|
||||
expect(h.start.outputs.map(o => o.name)).toEqual(["image", "mask", "item_index", "context"]);
|
||||
});
|
||||
|
||||
it("adds one typed output per connected value input", () => {
|
||||
const h = setup();
|
||||
h.connectValue(1, h.makeSource(10, "IMAGE"));
|
||||
h.connectValue(2, h.makeSource(11, "STRING"));
|
||||
expect(valueOutputs(h.start)).toEqual(["value_1:IMAGE", "value_2:STRING"]);
|
||||
expect(h.start.outputs[4].shape).toBe(6);
|
||||
});
|
||||
|
||||
it("keeps downstream wiring on the same value when a middle input is removed", () => {
|
||||
const h = setup();
|
||||
h.connectValue(1, h.makeSource(10, "IMAGE"));
|
||||
h.connectValue(2, h.makeSource(11, "STRING"));
|
||||
h.connectValue(3, h.makeSource(12, "INT"));
|
||||
const imageUser = h.makeTarget(20, "IMAGE");
|
||||
const stringUser = h.makeTarget(21, "STRING");
|
||||
const intUser = h.makeTarget(22, "INT");
|
||||
h.start.connect(4, imageUser, 0);
|
||||
h.start.connect(5, stringUser, 0);
|
||||
h.start.connect(6, intUser, 0);
|
||||
|
||||
h.disconnectValueAndCompact(2);
|
||||
|
||||
expect(valueOutputs(h.start)).toEqual(["value_1:IMAGE", "value_2:INT"]);
|
||||
expect(h.links.get(imageUser.inputs[0].link).origin_slot).toBe(4);
|
||||
expect(h.links.get(intUser.inputs[0].link).origin_slot).toBe(5);
|
||||
expect(stringUser.inputs[0].link).toBe(null);
|
||||
});
|
||||
|
||||
it("disconnects a consumer whose type no longer matches the input", () => {
|
||||
const h = setup();
|
||||
h.connectValue(1, h.makeSource(10, "IMAGE"));
|
||||
const imageUser = h.makeTarget(20, "IMAGE");
|
||||
h.start.connect(4, imageUser, 0);
|
||||
// Replace value_1's source with a STRING source.
|
||||
const input = h.start.inputs.find(i => i.name === "values.value_1");
|
||||
h.links.delete(input.link);
|
||||
input.link = null;
|
||||
h.connectValue(1, h.makeSource(11, "STRING"));
|
||||
expect(valueOutputs(h.start)).toEqual(["value_1:STRING"]);
|
||||
expect(imageUser.inputs[0].link).toBe(null);
|
||||
});
|
||||
|
||||
it("never removes an output that still has links", () => {
|
||||
const h = setup();
|
||||
h.connectValue(1, h.makeSource(10, "IMAGE"));
|
||||
h.start.connect(4, h.makeTarget(20, "*"), 0);
|
||||
const input = h.start.inputs.find(i => i.name === "values.value_1");
|
||||
h.links.delete(input.link);
|
||||
input.link = null;
|
||||
h.start.onConnectionsChange(1, 2, false);
|
||||
h.flush();
|
||||
expect(valueOutputs(h.start)).toEqual(["value_1:*"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user