Add ordered multi-image input support via virtual transport links

This commit is contained in:
Dariusz L
2026-08-14 04:25:08 +02:00
parent a942228ef3
commit c8b9e3ccc4
11 changed files with 2407 additions and 231 deletions
+147 -97
View File
@@ -5,21 +5,92 @@ import { webSocketManager } from "./utils/WebSocketManager.js";
import { scaleImageToFit, loadImage, blobToDataUrl, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
import { postImageBlob } from "./utils/ImageUploadUtils.js";
import { getImageAddMode, isFitOnAddEnabled } from "./utils/CanvasInputUtils.js";
import { getLayerForgeImageInputLinks, getLayerForgeImageInputSlot, getLayerForgeMaskInputSlot, hasLayerForgeImageInput, } from "./utils/MultiImageInputUtils.js";
const log = createModuleLogger('CanvasIO');
function imageBatchIdentity(sources) {
return sources.join('|');
}
function getBackendImageSources(data) {
if (Array.isArray(data?.input_images))
return data.input_images;
if (Array.isArray(data?.input_images_batch))
return data.input_images_batch;
if (data?.input_image)
return [{ data: data.input_image }];
return [];
}
function getBackendImageIdentity(data) {
if (Array.isArray(data?.input_images_batch)) {
return imageBatchIdentity(data.input_images_batch.map(image => image.data));
}
return data?.input_image;
const sources = getBackendImageSources(data);
return sources.length > 0 ? imageBatchIdentity(sources.map(image => image.data)) : undefined;
}
export class CanvasIO {
constructor(canvas) {
this.canvas = canvas;
this._saveInProgress = null;
}
getImageInputSlot() {
return getLayerForgeImageInputSlot(this.canvas.node);
}
getMaskInputSlot() {
return getLayerForgeMaskInputSlot(this.canvas.node);
}
getGraphLink(linkId) {
const graph = this.canvas.node.graph;
if (!graph || linkId == null)
return null;
for (const links of [graph.links, graph._links]) {
if (!links)
continue;
if (typeof links.get === 'function') {
const link = links.get(linkId) ?? links.get(String(linkId));
if (link)
return link;
}
const link = links[linkId] ?? links[String(linkId)];
if (link)
return link;
}
return null;
}
getImageInputIdentity() {
const virtualLinks = getLayerForgeImageInputLinks(this.canvas.node);
if (virtualLinks.length > 0) {
return `virtual:${virtualLinks.map(link => `${link.source_id}:${link.source_slot}`).join('|')}`;
}
return this.getImageInputSlot()?.link;
}
getConnectedImageSources() {
const graph = this.canvas.node.graph;
if (!graph)
return [];
const sources = [];
const seen = new Set();
const addSource = (sourceId, sourceSlot) => {
const sourceNode = graph.getNodeById?.(sourceId);
if (!sourceNode?.imgs?.length)
return;
const key = `${sourceId}:${sourceSlot}`;
if (seen.has(key))
return;
seen.add(key);
sources.push({ sourceNode, sourceId, sourceSlot });
};
for (const link of getLayerForgeImageInputLinks(this.canvas.node)) {
addSource(link.source_id, link.source_slot);
}
const nativeLinkId = this.getImageInputSlot()?.link;
const nativeLink = this.getGraphLink(nativeLinkId);
if (nativeLink) {
const sourceId = Number(nativeLink.origin_id ?? nativeLink.originId);
const sourceSlot = Number(nativeLink.origin_slot ?? nativeLink.originSlot ?? 0);
if (Number.isFinite(sourceId))
addSource(sourceId, Number.isFinite(sourceSlot) ? sourceSlot : 0);
}
return sources;
}
hasImageInput() {
return hasLayerForgeImageInput(this.canvas.node);
}
async addBatchImages(images, addMode, targetArea, logSuffix) {
for (let i = 0; i < images.length; i++) {
const imageSource = images[i];
@@ -316,15 +387,18 @@ export class CanvasIO {
log.debug("Node or inputs not ready");
return this.scheduleDataCheck();
}
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const imageLinkId = this.canvas.node.inputs[0].link;
const imageInput = this.getImageInputSlot();
if (this.hasImageInput()) {
const imageLinkId = imageInput?.link;
// Check if we already loaded this link
if (this.canvas.lastLoadedLinkId === imageLinkId) {
if (imageLinkId != null && this.canvas.lastLoadedLinkId === imageLinkId) {
log.debug(`Link ${imageLinkId} already loaded via new system, marking as initialized`);
this.canvas.dataInitialized = true;
return;
}
const imageData = window.app.nodeOutputs[imageLinkId];
const imageData = imageLinkId != null
? window.app.nodeOutputs[imageLinkId]
: undefined;
if (imageData) {
log.debug("Found image data:", imageData);
await this.processImageData(imageData);
@@ -339,8 +413,9 @@ export class CanvasIO {
// No input connected, mark as initialized to stop repeated checks
this.canvas.dataInitialized = true;
}
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
const maskLinkId = this.canvas.node.inputs[1].link;
const maskInput = this.getMaskInputSlot();
if (maskInput?.link != null) {
const maskLinkId = maskInput.link;
const maskData = window.app.nodeOutputs[maskLinkId];
if (maskData) {
log.debug("Found mask data:", maskData);
@@ -364,91 +439,65 @@ export class CanvasIO {
let imageLoaded = false;
let maskLoaded = false;
let imageChanged = false;
// First, try to get data from connected node's output if available (IMAGES)
if (allowImage && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const linkId = this.canvas.node.inputs[0].link;
const graph = this.canvas.node.graph;
// Always check if images have changed first
if (graph) {
const link = graph.links[linkId];
if (link) {
const sourceNode = graph.getNodeById(link.origin_id);
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
// Create current batch identifier (all image sources combined)
const currentBatchImageSrcs = imageBatchIdentity(sourceNode.imgs.map((img) => img.src));
// Check if this is the same link we loaded before
if (this.canvas.lastLoadedLinkId === linkId) {
// Same link, check if images actually changed
if (this.canvas.lastLoadedImageSrc !== currentBatchImageSrcs) {
log.info(`Batch images changed for link ${linkId} (${sourceNode.imgs.length} images), will reload...`);
log.debug(`Previous batch hash: ${this.canvas.lastLoadedImageSrc?.substring(0, 100)}...`);
log.debug(`Current batch hash: ${currentBatchImageSrcs.substring(0, 100)}...`);
imageChanged = true;
// Clear the inputDataLoaded flag to force reload from backend
this.canvas.inputDataLoaded = false;
// Clear the lastLoadedImageSrc to force reload
this.canvas.lastLoadedImageSrc = undefined;
// Clear backend data to force fresh load
fetch(`/layerforge/clear_input_data/${nodeId}`, { method: 'POST' })
.then(() => log.debug("Backend input data cleared due to image change"))
.catch(err => log.error("Failed to clear backend data:", err));
}
else {
log.debug(`Batch images for link ${linkId} unchanged (${sourceNode.imgs.length} images)`);
imageLoaded = true;
}
}
else {
// Different link or first load
log.info(`New link ${linkId} detected, will load ${sourceNode.imgs.length} images`);
imageChanged = false; // It's not a change, it's a new link
imageLoaded = false; // Need to load
// Reset the inputDataLoaded flag for new link
this.canvas.inputDataLoaded = false;
}
}
// First, try to get data from every connected image source. The visible
// input can have several virtual links, while legacy native links remain
// supported as a fallback.
const connectedImageSources = this.getConnectedImageSources();
if (allowImage && connectedImageSources.length > 0) {
const imageInputIdentity = this.getImageInputIdentity() ?? 'image-input';
const currentSourceIdentities = connectedImageSources.map(({ sourceNode }) => imageBatchIdentity(sourceNode.imgs.map((img) => img.src)));
const currentBatchImageSrcs = imageBatchIdentity(currentSourceIdentities);
if (this.canvas.lastLoadedLinkId === imageInputIdentity) {
if (this.canvas.lastLoadedImageSrc !== currentBatchImageSrcs) {
log.info(`Connected input images changed (${connectedImageSources.length} source(s)), will reload...`);
log.debug(`Previous image hash: ${this.canvas.lastLoadedImageSrc?.substring(0, 100)}...`);
log.debug(`Current image hash: ${currentBatchImageSrcs.substring(0, 100)}...`);
imageChanged = true;
this.canvas.inputDataLoaded = false;
this.canvas.lastLoadedImageSrc = undefined;
fetch(`/layerforge/clear_input_data/${nodeId}`, { method: 'POST' })
.then(() => log.debug("Backend input data cleared due to image change"))
.catch(err => log.error("Failed to clear backend data:", err));
}
else {
log.debug(`Connected input images unchanged (${connectedImageSources.length} source(s))`);
imageLoaded = true;
}
}
else {
log.info(`New image input connection set detected, will load ${connectedImageSources.length} source(s)`);
imageChanged = false;
imageLoaded = false;
this.canvas.inputDataLoaded = false;
}
if (!imageLoaded || imageChanged) {
// Reset the inputDataLoaded flag when images change
if (imageChanged) {
this.canvas.inputDataLoaded = false;
log.info("Resetting inputDataLoaded flag due to image change");
}
if (this.canvas.node.graph) {
const graph2 = this.canvas.node.graph;
const link2 = graph2.links[linkId];
if (link2) {
const sourceNode = graph2.getNodeById(link2.origin_id);
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
// The connected node has images in its output - handle multiple images (batch)
log.info(`Found ${sourceNode.imgs.length} image(s) in connected node's output, loading all`);
// Create a combined source identifier for batch detection
const batchImageSrcs = imageBatchIdentity(sourceNode.imgs.map((img) => img.src));
// Mark this link and batch sources as loaded
this.canvas.lastLoadedLinkId = linkId;
this.canvas.lastLoadedImageSrc = batchImageSrcs;
// Don't clear layers - just add new ones
if (imageChanged) {
log.info("Image change detected, will add new layers");
}
// Determine add mode
const addMode = getImageAddMode(this.canvas.node.widgets);
// Add all images from the batch as separate layers
await this.addBatchImages(sourceNode.imgs, addMode, this.canvas.outputAreaBounds, 'to canvas');
this.canvas.inputDataLoaded = true;
imageLoaded = true;
log.info(`All ${sourceNode.imgs.length} input images from batch added as separate layers`);
this.canvas.render();
this.canvas.saveState();
}
}
const sourceImages = connectedImageSources.flatMap(({ sourceNode }) => sourceNode.imgs);
if (sourceImages.length > 0) {
log.info(`Found ${sourceImages.length} image(s) across ${connectedImageSources.length} connected source(s), loading all`);
const sourceIdentities = connectedImageSources.map(({ sourceNode }) => imageBatchIdentity(sourceNode.imgs.map((img) => img.src)));
const batchImageSrcs = imageBatchIdentity(sourceIdentities);
this.canvas.lastLoadedLinkId = imageInputIdentity;
this.canvas.lastLoadedImageSrc = batchImageSrcs;
if (imageChanged)
log.info("Image change detected, will add new layers");
const addMode = getImageAddMode(this.canvas.node.widgets);
await this.addBatchImages(sourceImages, addMode, this.canvas.outputAreaBounds, 'to canvas');
this.canvas.inputDataLoaded = true;
imageLoaded = true;
log.info(`All ${sourceImages.length} connected input images added as separate layers`);
this.canvas.render();
this.canvas.saveState();
}
}
}
// Check for mask input separately (from nodeOutputs) ONLY when allowed
if (allowMask && this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
const maskLinkId = this.canvas.node.inputs[1].link;
const maskInput = this.getMaskInputSlot();
if (allowMask && maskInput?.link != null) {
const maskLinkId = maskInput.link;
// Check if we already loaded this mask link
if (this.canvas.lastLoadedMaskLinkId === maskLinkId) {
log.debug(`Mask link ${maskLinkId} already loaded`);
@@ -459,7 +508,7 @@ export class CanvasIO {
const graph = this.canvas.node.graph;
let maskOutput = null;
if (graph) {
const link = graph.links[maskLinkId];
const link = this.getGraphLink(maskLinkId);
if (link && link.origin_id) {
// Use origin_id to get the actual node output
const nodeOutput = window.app?.nodeOutputs?.[link.origin_id];
@@ -552,8 +601,8 @@ export class CanvasIO {
}
}
// Only check backend if we have actual inputs connected
const hasImageInput = this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link;
const hasMaskInput = this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link;
const hasImageInput = this.hasImageInput();
const hasMaskInput = this.getMaskInputSlot()?.link != null;
// If mask input is disconnected, clear any currently applied mask to ensure full separation
if (!hasMaskInput) {
this.canvas.maskAppliedFromInput = false;
@@ -588,8 +637,8 @@ export class CanvasIO {
imageLoaded = true; // Mark images as already loaded to skip reloading them
}
// Check if we already loaded image data (by checking the current link)
if (allowImage && !imageLoaded && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const currentLinkId = this.canvas.node.inputs[0].link;
if (allowImage && !imageLoaded && hasImageInput) {
const currentLinkId = this.getImageInputIdentity();
if (this.canvas.lastLoadedLinkId !== currentLinkId) {
// Mark this link as loaded
this.canvas.lastLoadedLinkId = currentLinkId;
@@ -598,8 +647,8 @@ export class CanvasIO {
}
// Check for mask data from backend ONLY when mask input is actually connected AND allowed
// Only reset if the mask link actually changed
if (allowMask && hasMaskInput && this.canvas.node.inputs && this.canvas.node.inputs[1]) {
const currentMaskLinkId = this.canvas.node.inputs[1].link;
if (allowMask && hasMaskInput) {
const currentMaskLinkId = this.getMaskInputSlot()?.link;
// Only reset if this is a different mask link than what we loaded before
if (this.canvas.lastLoadedMaskLinkId !== currentMaskLinkId) {
maskLoaded = false;
@@ -635,12 +684,13 @@ export class CanvasIO {
const addMode = getImageAddMode(this.canvas.node.widgets);
// Load input image(s) only if image input is actually connected, not already loaded, and allowed
if (allowImage && !imageLoaded && hasImageInput) {
if (inputData.input_images_batch) {
// Handle batch of images
const batch = inputData.input_images_batch;
log.info(`Processing batch of ${batch.length} images from backend`);
if (inputData.input_images || inputData.input_images_batch) {
// Handle ordered images from multiple inputs, while retaining
// the legacy tensor-batch payload for older workflows.
const batch = getBackendImageSources(inputData);
log.info(`Processing ${batch.length} ordered input images from backend`);
await this.addBatchImages(batch.map((imgData) => imgData.data), addMode, this.canvas.outputAreaBounds, 'from backend');
log.info(`All ${batch.length} batch images added from backend`);
log.info(`All ${batch.length} input images added from backend`);
this.canvas.render();
this.canvas.saveState();
}
@@ -657,7 +707,7 @@ export class CanvasIO {
log.debug("No input image data from backend");
}
}
else if (!hasImageInput && (inputData.input_images_batch || inputData.input_image)) {
else if (!hasImageInput && (inputData.input_images || inputData.input_images_batch || inputData.input_image)) {
log.debug("Backend has image data but no image input connected, skipping image load");
}
// Handle mask separately only if mask input is actually connected, allowed, and not already loaded
+834 -7
View File
@@ -19,6 +19,7 @@ import { exportCanvasImage } from "./utils/CanvasExportUtils.js";
import { getFlattenedCanvasBlob } from "./utils/CanvasBlobUtils.js";
import { loadPreviewImage } from "./utils/PreviewUtils.js";
import { getImageAddMode } from "./utils/CanvasInputUtils.js";
import { addLayerForgeImageInputLink, clearLayerForgeImageInputLinks, getLayerForgeImageInputLinks, getLayerForgeImageInputSlot, getLayerForgeMaskInputSlot, hasLayerForgeImageInput, LAYERFORGE_MAX_IMAGE_INPUTS, removeLayerForgeImageInputLink, } from "./utils/MultiImageInputUtils.js";
import { fetchMattingModelStatus, fetchMattingSettings, saveMattingSettings as saveMattingSettingsToServer, } from "./utils/MattingUtils.js";
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
const log = createModuleLogger('Canvas_view');
@@ -1660,12 +1661,806 @@ async function createCanvasWidget(node, widget, app) {
};
}
const canvasNodeInstances = new Map();
let layerForgeQuickCreateMenu = null;
let layerForgeQuickCreateCanvas = null;
let layerForgeQuickCreateCleanup = null;
let layerForgeQuickCreatePending = false;
let layerForgeQuickCreateToken = 0;
let layerForgeLastCapturedDropAt = 0;
let layerForgeSuppressNativeDropUntil = 0;
const isLayerForgeTransportInput = (name) => /^input_image_\d+$/i.test(String(name || ""));
const getLayerForgeGraphLink = (node, linkId) => {
const graph = node?.graph || app.graph;
if (!graph || linkId == null)
return null;
for (const links of [graph.links, graph._links]) {
if (!links)
continue;
if (typeof links.get === 'function') {
const link = links.get(linkId) ?? links.get(String(linkId));
if (link)
return link;
}
const link = links[linkId] ?? links[String(linkId)];
if (link)
return link;
}
return null;
};
const getLayerForgeSlotIndex = (slots, rawSlot) => {
if (!Array.isArray(slots))
return -1;
if (typeof rawSlot === 'number')
return slots[rawSlot] ? rawSlot : -1;
for (const key of ['slot_index', 'slot', 'index']) {
const value = rawSlot?.[key];
if (typeof value === 'number' && slots[value])
return value;
}
if (rawSlot) {
const directIndex = slots.indexOf(rawSlot);
if (directIndex >= 0)
return directIndex;
const name = typeof rawSlot === 'string' ? rawSlot : rawSlot?.name;
if (name)
return slots.findIndex(slot => slot?.name === name);
}
return -1;
};
const getLayerForgePendingConnectorLink = (canvas) => {
const renderLinks = canvas?.linkConnector?.renderLinks;
const link = renderLinks?.[0] || renderLinks?.at?.(0);
if (!link)
return null;
const endpointNode = link.node
|| link.fromNode
|| link.originNode
|| link.sourceNode
|| link.toNode
|| link.targetNode
|| link.inputNode
|| link.outputNode;
const endpointSlot = link.fromSlot ?? link.slot ?? link.output ?? link.input ?? link.toSlot ?? {};
if (!endpointNode)
return null;
const inputIndex = getLayerForgeSlotIndex(endpointNode.inputs, endpointSlot);
const outputIndex = getLayerForgeSlotIndex(endpointNode.outputs, endpointSlot);
const toType = String(link.toType || link.targetType || link.targetSlotType || '').toLowerCase();
let direction = toType.includes('output') ? 'from_input' : 'from_output';
if (inputIndex >= 0 && outputIndex < 0)
direction = 'from_input';
if (outputIndex >= 0 && inputIndex < 0)
direction = 'from_output';
if (direction === 'from_input') {
const input = endpointNode.inputs?.[inputIndex] || endpointSlot;
const inputName = String(input?.name || '');
if (endpointNode?.comfyClass !== 'LayerForgeNode'
&& endpointNode?.type !== 'LayerForgeNode')
return null;
if (inputName !== 'input_image' && !isLayerForgeTransportInput(inputName))
return null;
return {
direction,
targetNode: endpointNode,
targetSlot: inputIndex,
};
}
const output = endpointNode.outputs?.[outputIndex] || endpointSlot || {};
return {
direction,
sourceNode: endpointNode,
sourceSlot: Math.max(0, outputIndex),
sourceType: String(output?.type || output?.datatype || output?.name || 'IMAGE'),
};
};
const getLayerForgePointerGraphPosition = (canvas, event) => {
if (Number.isFinite(event?.canvasX) && Number.isFinite(event?.canvasY)) {
return [event.canvasX, event.canvasY];
}
const rect = canvas?.canvas?.getBoundingClientRect?.();
const scale = canvas?.ds?.scale || 1;
const offset = canvas?.ds?.offset || [0, 0];
if (rect && Number.isFinite(event?.clientX) && Number.isFinite(event?.clientY)) {
return [
(event.clientX - rect.left) / scale - offset[0],
(event.clientY - rect.top) / scale - offset[1],
];
}
return [0, 0];
};
const getLayerForgeConnectionPosition = (node, isInput, slotIndex) => {
const normalize = (point) => {
if (!point || !Number.isFinite(Number(point[0])) || !Number.isFinite(Number(point[1])))
return null;
return [Number(point[0]), Number(point[1])];
};
const modernPosition = normalize(isInput
? node?.getInputPos?.(slotIndex)
: node?.getOutputPos?.(slotIndex));
if (modernPosition)
return modernPosition;
try {
if (typeof node?.getConnectionPos === 'function') {
const output = [0, 0];
const legacyPosition = normalize(node.getConnectionPos(isInput, slotIndex, output)) || normalize(output);
if (legacyPosition)
return legacyPosition;
}
}
catch {
// Fall through to stable LiteGraph geometry for older frontend builds.
}
const position = node?.pos || [0, 0];
const size = node?.size || [160, 0];
const slotY = Number(position[1] || 0) + 40 + Math.max(0, slotIndex) * 20;
return [
Number(position[0] || 0) + (isInput ? 0 : Number(size[0] || 160)),
slotY,
];
};
const getLayerForgeVirtualLinkGeometry = (targetNode, link) => {
const graph = targetNode?.graph || app.graph;
const sourceNode = graph?.getNodeById?.(Number(link?.source_id));
if (!sourceNode)
return null;
const inputSlot = getLayerForgeImageInputSlot(targetNode);
const inputIndex = Math.max(0, targetNode?.inputs?.indexOf(inputSlot) ?? 0);
const source = getLayerForgeConnectionPosition(sourceNode, false, Number(link?.source_slot) || 0);
const target = getLayerForgeConnectionPosition(targetNode, true, inputIndex);
if (!source || !target)
return null;
const midpoint = [
(source[0] + target[0]) / 2,
(source[1] + target[1]) / 2,
];
return { source, target, midpoint, sourceNode };
};
const getLayerForgeVirtualLinkColor = (link) => {
const colors = globalThis.LGraphCanvas?.link_type_colors || {};
const rawType = String(link?.source_type || 'IMAGE');
for (const candidate of [rawType, rawType.toUpperCase(), rawType.toLowerCase()]) {
if (colors[candidate])
return colors[candidate];
}
return '#5aa9f0';
};
const drawLayerForgeVirtualLinks = (canvas, context) => {
const graph = canvas?.graph || app.graph;
if (!graph?._nodes || canvas.links_render_mode === globalThis.LiteGraph?.HIDDEN_LINK)
return;
for (const targetNode of graph._nodes) {
if (targetNode?.comfyClass !== 'LayerForgeNode' && targetNode?.type !== 'LayerForgeNode')
continue;
const links = getLayerForgeImageInputLinks(targetNode);
links.forEach((link, index) => {
const geometry = getLayerForgeVirtualLinkGeometry(targetNode, link);
if (!geometry)
return;
const highlighted = Boolean(targetNode.selected || geometry.sourceNode.selected);
const color = highlighted ? '#ffffff' : getLayerForgeVirtualLinkColor(link);
const width = Number(canvas.connections_width) || 3;
const controlOffset = 80;
context.save();
context.lineJoin = 'round';
context.shadowBlur = 0;
context.shadowColor = 'transparent';
context.beginPath();
context.moveTo(geometry.source[0], geometry.source[1]);
context.bezierCurveTo(geometry.source[0] + controlOffset, geometry.source[1], geometry.target[0] - controlOffset, geometry.target[1], geometry.target[0], geometry.target[1]);
context.lineWidth = width + 4;
context.strokeStyle = canvas.render_connections_border !== false && !canvas.low_quality
? 'rgba(0, 0, 0, 0.5)'
: 'transparent';
if (context.strokeStyle !== 'transparent')
context.stroke();
context.beginPath();
context.moveTo(geometry.source[0], geometry.source[1]);
context.bezierCurveTo(geometry.source[0] + controlOffset, geometry.source[1], geometry.target[0] - controlOffset, geometry.target[1], geometry.target[0], geometry.target[1]);
context.lineWidth = width;
context.strokeStyle = color;
context.stroke();
if (canvas.linkMarkerShape !== 0 && (canvas.ds?.scale ?? 1) >= 0.6 && canvas.highquality_render !== false) {
context.beginPath();
context.arc(geometry.midpoint[0], geometry.midpoint[1], 5, 0, Math.PI * 2);
context.fillStyle = color;
context.fill();
context.fillStyle = highlighted ? '#222' : '#fff';
context.font = 'bold 7px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(String(index + 1), geometry.midpoint[0], geometry.midpoint[1] + 0.3);
}
context.restore();
});
}
};
const getLayerForgeGraphPosition = (canvas, event) => {
try {
canvas?.adjustMouseEvent?.(event);
}
catch {
// Older LiteGraph builds may not expose adjustMouseEvent.
}
if (Array.isArray(canvas?.graph_mouse))
return [canvas.graph_mouse[0], canvas.graph_mouse[1]];
if (Number.isFinite(event?.canvasX) && Number.isFinite(event?.canvasY)) {
return [event.canvasX, event.canvasY];
}
const rect = canvas?.canvas?.getBoundingClientRect?.();
const scale = canvas?.ds?.scale || 1;
const offset = canvas?.ds?.offset || [0, 0];
if (rect && Number.isFinite(event?.clientX) && Number.isFinite(event?.clientY)) {
return [
(event.clientX - rect.left) / scale - offset[0],
(event.clientY - rect.top) / scale - offset[1],
];
}
return [0, 0];
};
const hitTestLayerForgeVirtualLinks = (graph, x, y) => {
let best = null;
for (const targetNode of graph?._nodes || []) {
if (targetNode?.comfyClass !== 'LayerForgeNode' && targetNode?.type !== 'LayerForgeNode')
continue;
getLayerForgeImageInputLinks(targetNode).forEach((link, index) => {
const geometry = getLayerForgeVirtualLinkGeometry(targetNode, link);
if (!geometry)
return;
const distance = Math.hypot(x - geometry.midpoint[0], y - geometry.midpoint[1]);
if (distance <= 18 && (!best || distance < best.distance)) {
best = {
targetNode,
index,
point: geometry.midpoint,
distance,
};
}
});
}
return best;
};
const getLayerForgeClientPosition = (canvas, point) => {
const rect = canvas?.canvas?.getBoundingClientRect?.();
if (!rect)
return null;
const scale = canvas?.ds?.scale || 1;
const offset = canvas?.ds?.offset || [0, 0];
return {
x: rect.left + (point[0] + offset[0]) * scale,
y: rect.top + (point[1] + offset[1]) * scale,
};
};
const openLayerForgeVirtualLinkMenu = (canvas, hit, event) => {
const ContextMenu = globalThis.LiteGraph?.ContextMenu;
if (typeof ContextMenu !== 'function')
return;
const clientPoint = getLayerForgeClientPosition(canvas, hit.point);
const clientX = Number.isFinite(event?.clientX) ? event.clientX : clientPoint?.x || 0;
const clientY = Number.isFinite(event?.clientY) ? event.clientY : clientPoint?.y || 0;
const PointerEventConstructor = globalThis.PointerEvent;
const MouseEventConstructor = globalThis.MouseEvent;
let menuEvent;
try {
const EventConstructor = PointerEventConstructor || MouseEventConstructor;
menuEvent = EventConstructor
? new EventConstructor('pointerdown', {
clientX,
clientY,
bubbles: true,
cancelable: true,
})
: { clientX, clientY };
}
catch {
menuEvent = { clientX, clientY };
}
let menuInstance = null;
const closeMenu = () => {
menuInstance?.close?.();
menuInstance?.remove?.();
};
menuInstance = new ContextMenu([
{
content: 'Remove connection',
callback: () => {
if (removeLayerForgeImageInputLink(hit.targetNode, hit.index)) {
hit.targetNode.setDirtyCanvas?.(true, true);
canvas?.setDirty?.(true, true);
canvas?.graph?.setDirtyCanvas?.(true, true);
app.graph?.change?.();
}
closeMenu();
},
},
], { event: menuEvent });
};
const clearLayerForgeTemporaryConnector = (canvas) => {
const connector = canvas?.linkConnector;
connector?.reset?.();
if (Array.isArray(connector?.renderLinks))
connector.renderLinks.length = 0;
canvas?.setDirty?.(true, true);
(canvas?.graph || app.graph)?.setDirtyCanvas?.(true, true);
};
const createLayerForgeLoadImageNode = (canvas, targetNode, position) => {
const graph = canvas?.graph || app.graph;
const LiteGraph = globalThis.LiteGraph;
if (!graph || typeof LiteGraph?.createNode !== 'function' || !targetNode)
return false;
if (getLayerForgeImageInputLinks(targetNode).length >= LAYERFORGE_MAX_IMAGE_INPUTS)
return false;
const node = LiteGraph.createNode('LoadImage');
if (!node)
return false;
node.pos = [position[0], position[1]];
graph.add(node);
const imageOutputIndex = Math.max(0, node.outputs?.findIndex((output) => {
const type = String(output?.type || output?.datatype || output?.name || '').toUpperCase();
return type.includes('IMAGE');
}) ?? 0);
const outputPosition = getLayerForgeConnectionPosition(node, false, imageOutputIndex);
if (outputPosition) {
node.pos = [
Number(node.pos?.[0] || 0) + position[0] - outputPosition[0],
Number(node.pos?.[1] || 0) + position[1] - outputPosition[1],
];
}
const output = node.outputs?.[imageOutputIndex];
addLayerForgeImageInputLink(targetNode, {
source_id: Number(node.id),
source_slot: imageOutputIndex,
source_type: String(output?.type || 'IMAGE'),
});
node.setDirtyCanvas?.(true, true);
targetNode.setDirtyCanvas?.(true, true);
graph.setDirtyCanvas?.(true, true);
app.graph?.change?.();
return true;
};
const getLayerForgeQuickCreateMenuEvent = (detail) => {
const clientX = Number(detail?.clientX) || 0;
const clientY = Number(detail?.clientY) || 0;
const PointerEventConstructor = globalThis.PointerEvent;
const MouseEventConstructor = globalThis.MouseEvent;
try {
const EventConstructor = PointerEventConstructor || MouseEventConstructor;
return EventConstructor
? new EventConstructor('pointerdown', {
clientX,
clientY,
bubbles: true,
cancelable: true,
})
: { clientX, clientY };
}
catch {
return { clientX, clientY };
}
};
const closeLayerForgeNativeNodeSearchSoon = () => {
const documentObject = globalThis.document;
if (!documentObject)
return;
const close = () => {
const container = documentObject.querySelector?.('.node-search-box-dialog-mask, .invisible-dialog-root, .comfy-vue-node-search-container');
const input = container?.querySelector?.('input')
|| documentObject.querySelector?.('input[id^="comfy-vue-node-search-box-input-"]');
if (!container && !input)
return;
const KeyboardEventConstructor = globalThis.KeyboardEvent;
if (typeof KeyboardEventConstructor !== 'function')
return;
const init = {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true,
};
input?.dispatchEvent?.(new KeyboardEventConstructor('keydown', init));
container?.dispatchEvent?.(new KeyboardEventConstructor('keydown', init));
documentObject.dispatchEvent?.(new KeyboardEventConstructor('keydown', init));
};
for (const delay of [0, 16, 50, 120])
setTimeout(close, delay);
};
const openLayerForgeQuickCreateMenu = (canvas, targetNode, detail) => {
const ContextMenu = globalThis.LiteGraph?.ContextMenu;
if (typeof ContextMenu !== 'function' || !targetNode)
return;
layerForgeQuickCreateMenu?.close?.();
layerForgeQuickCreateMenu?.remove?.();
layerForgeQuickCreateMenu = null;
const position = [
Number(detail?.canvasX) || 0,
Number(detail?.canvasY) || 0,
];
const finish = () => {
clearLayerForgeTemporaryConnector(canvas);
layerForgeQuickCreatePending = false;
layerForgeQuickCreateMenu?.close?.();
layerForgeQuickCreateMenu?.remove?.();
layerForgeQuickCreateMenu = null;
};
const menuInstance = new ContextMenu([
{
content: 'Load image',
callback: () => {
createLayerForgeLoadImageNode(canvas, targetNode, position);
finish();
},
},
], { event: getLayerForgeQuickCreateMenuEvent(detail) });
layerForgeQuickCreateMenu = menuInstance;
menuInstance.controller?.signal?.addEventListener?.('abort', () => {
if (layerForgeQuickCreateMenu !== menuInstance)
return;
clearLayerForgeTemporaryConnector(canvas);
layerForgeQuickCreatePending = false;
layerForgeQuickCreateMenu = null;
}, { once: true });
};
const getLayerForgeTargetInputState = (pending) => {
const targetNode = pending?.targetNode;
const input = getLayerForgeImageInputSlot(targetNode);
return {
inputLinkId: input?.link ?? null,
virtualLinkCount: getLayerForgeImageInputLinks(targetNode).length,
};
};
const scheduleLayerForgeQuickCreateMenu = (canvas, event, pending) => {
if (pending?.direction !== 'from_input' || !pending.targetNode)
return false;
const token = ++layerForgeQuickCreateToken;
const [canvasX, canvasY] = getLayerForgePointerGraphPosition(canvas, event);
const detail = {
clientX: event?.clientX,
clientY: event?.clientY,
canvasX,
canvasY,
originalEvent: event,
};
const linkSnapshot = { ...pending };
const before = getLayerForgeTargetInputState(linkSnapshot);
const graph = canvas?.graph || app.graph;
const beforeGraphVersion = Number(graph?._version) || 0;
const releaseConnectorHold = holdLayerForgeConnectorReset(canvas);
layerForgeQuickCreatePending = true;
layerForgeSuppressNativeDropUntil = performance.now() + 1000;
closeLayerForgeNativeNodeSearchSoon();
const checkConnected = () => {
if (token !== layerForgeQuickCreateToken)
return true;
const current = getLayerForgeTargetInputState(linkSnapshot);
const graphVersion = Number(graph?._version) || 0;
return current.inputLinkId != null
|| current.virtualLinkCount > before.virtualLinkCount
|| graphVersion > beforeGraphVersion;
};
const openIfStillEmpty = () => {
if (token !== layerForgeQuickCreateToken) {
releaseConnectorHold?.();
return;
}
if (checkConnected()) {
releaseConnectorHold?.();
layerForgeQuickCreatePending = false;
clearLayerForgeTemporaryConnector(canvas);
return;
}
releaseConnectorHold?.();
layerForgeQuickCreatePending = false;
openLayerForgeQuickCreateMenu(canvas, linkSnapshot.targetNode, detail);
};
setTimeout(openIfStillEmpty, 70);
return true;
};
const holdLayerForgeConnectorReset = (canvas) => {
const events = canvas?.linkConnector?.events;
if (!events)
return null;
const preventReset = (event) => event?.preventDefault?.();
events.addEventListener?.('reset', preventReset, { once: true });
return () => events.removeEventListener?.('reset', preventReset, { once: true });
};
const hasLayerForgePendingConnection = (canvas) => Boolean(canvas?.connecting_node
|| canvas?.connectingNode
|| canvas?.connecting_input
|| canvas?.connectingInput
|| canvas?.linkConnector?.renderLinks?.length);
const shouldSuppressLayerForgeNativeDrop = (type) => (type === 'dropped-on-canvas'
&& Boolean(layerForgeQuickCreateMenu || layerForgeQuickCreatePending)
&& performance.now() < layerForgeSuppressNativeDropUntil);
const primeLayerForgeInputDropSuppression = (canvas) => {
const pending = getLayerForgePendingConnectorLink(canvas);
if (pending?.direction !== 'from_input')
return false;
layerForgeQuickCreatePending = true;
layerForgeSuppressNativeDropUntil = performance.now() + 1000;
return true;
};
const installLayerForgeQuickCreateCapture = (canvas) => {
const events = canvas?.linkConnector?.events;
if (!canvas?.canvas || !events)
return false;
if (canvas === layerForgeQuickCreateCanvas && canvas.__layerForgeQuickCreateInstalled)
return true;
layerForgeQuickCreateCleanup?.();
layerForgeQuickCreateCleanup = null;
layerForgeQuickCreateCanvas = canvas;
canvas.__layerForgeQuickCreateInstalled = true;
const handler = (event) => {
if (layerForgeQuickCreateMenu
|| event?.target?.closest?.('.litecontextmenu'))
return;
if (event?.button > 0 || performance.now() - layerForgeLastCapturedDropAt < 80)
return;
const pending = getLayerForgePendingConnectorLink(canvas);
if (!pending)
return;
const [x, y] = getLayerForgePointerGraphPosition(canvas, event);
if (pending.direction === 'from_output') {
const target = (canvas.graph?._nodes || []).find((node) => {
if (node?.comfyClass !== 'LayerForgeNode' && node?.type !== 'LayerForgeNode')
return false;
const inputSlot = getLayerForgeImageInputSlot(node);
const inputIndex = Math.max(0, node.inputs?.indexOf(inputSlot) ?? 0);
const dot = getLayerForgeConnectionPosition(node, true, inputIndex);
return dot && Math.hypot(x - dot[0], y - dot[1]) <= 18;
});
if (!target || !pending.sourceNode || target === pending.sourceNode)
return;
const added = addLayerForgeImageInputLink(target, {
source_id: Number(pending.sourceNode.id),
source_slot: Number(pending.sourceSlot) || 0,
source_type: pending.sourceType || 'IMAGE',
});
if (!added)
return;
layerForgeLastCapturedDropAt = performance.now();
event.preventDefault?.();
event.stopPropagation?.();
event.stopImmediatePropagation?.();
clearLayerForgeTemporaryConnector(canvas);
return;
}
if (scheduleLayerForgeQuickCreateMenu(canvas, event, pending)) {
layerForgeLastCapturedDropAt = performance.now();
closeLayerForgeNativeNodeSearchSoon();
event.preventDefault?.();
event.stopPropagation?.();
event.stopImmediatePropagation?.();
}
};
const pointerTargets = [
globalThis.window,
globalThis.document,
canvas.canvas,
];
for (const target of pointerTargets) {
target?.addEventListener?.('pointerup', handler, true);
target?.addEventListener?.('mouseup', handler, true);
}
const originalDispatch = typeof events.dispatch === 'function' ? events.dispatch : null;
const originalDispatchEvent = typeof events.dispatchEvent === 'function' ? events.dispatchEvent : null;
let wrappedDispatch = null;
let wrappedDispatchEvent = null;
if (originalDispatch) {
wrappedDispatch = function dispatchWithLayerForgeDropGuard(type, detail) {
if (type === 'before-drop-links')
primeLayerForgeInputDropSuppression(canvas);
if (shouldSuppressLayerForgeNativeDrop(type))
return false;
return originalDispatch.call(events, type, detail);
};
events.dispatch = wrappedDispatch;
}
if (originalDispatchEvent) {
wrappedDispatchEvent = function dispatchEventWithLayerForgeDropGuard(event) {
if (event?.type === 'before-drop-links')
primeLayerForgeInputDropSuppression(canvas);
if (shouldSuppressLayerForgeNativeDrop(event?.type)) {
event?.preventDefault?.();
event?.stopPropagation?.();
return false;
}
return originalDispatchEvent.call(events, event);
};
events.dispatchEvent = wrappedDispatchEvent;
}
const beforeDropLinksHandler = () => {
primeLayerForgeInputDropSuppression(canvas);
};
const droppedOnCanvasHandler = (event) => {
if (shouldSuppressLayerForgeNativeDrop(event?.type)) {
event?.preventDefault?.();
event?.stopPropagation?.();
event?.stopImmediatePropagation?.();
}
};
events.addEventListener?.('before-drop-links', beforeDropLinksHandler, { capture: true });
events.addEventListener?.('dropped-on-canvas', droppedOnCanvasHandler, { capture: true });
layerForgeQuickCreateCleanup = () => {
for (const target of pointerTargets) {
target?.removeEventListener?.('pointerup', handler, true);
target?.removeEventListener?.('mouseup', handler, true);
}
events.removeEventListener?.('before-drop-links', beforeDropLinksHandler, { capture: true });
events.removeEventListener?.('dropped-on-canvas', droppedOnCanvasHandler, { capture: true });
if (wrappedDispatch && events.dispatch === wrappedDispatch)
events.dispatch = originalDispatch;
if (wrappedDispatchEvent && events.dispatchEvent === wrappedDispatchEvent) {
events.dispatchEvent = originalDispatchEvent;
}
canvas.__layerForgeQuickCreateInstalled = false;
if (layerForgeQuickCreateCanvas === canvas)
layerForgeQuickCreateCanvas = null;
layerForgeQuickCreatePending = false;
};
return true;
};
const installLayerForgeVirtualWirePatch = () => {
const canvas = app.canvas;
if (!canvas)
return;
installLayerForgeQuickCreateCapture(canvas);
if (canvas.__layerForgeVirtualWirePatched || typeof canvas.drawConnections !== 'function')
return;
const originalDrawConnections = canvas.drawConnections;
canvas.__layerForgeVirtualWirePatched = true;
canvas.drawConnections = function drawConnectionsWithLayerForgeLinks(context) {
const result = originalDrawConnections.apply(this, arguments);
const connectionContext = context || this.bgctx || this.ctx;
const onConnectionLayer = connectionContext?.canvas === this?.bgcanvas
|| connectionContext === this?.bgctx
|| !this?.bgcanvas;
if (connectionContext && onConnectionLayer) {
drawLayerForgeVirtualLinks(this, connectionContext);
}
return result;
};
const originalProcessMouseDown = canvas.processMouseDown;
canvas.processMouseDown = function processMouseDownWithLayerForgeLinks(event) {
if (!hasLayerForgePendingConnection(this)) {
const [x, y] = getLayerForgeGraphPosition(this, event);
const hit = hitTestLayerForgeVirtualLinks(this.graph || app.graph, x, y);
if (hit) {
openLayerForgeVirtualLinkMenu(this, hit, event);
event?.preventDefault?.();
event?.stopImmediatePropagation?.();
return true;
}
}
return originalProcessMouseDown?.apply(this, arguments);
};
const linkPointerHandler = (event) => {
if (hasLayerForgePendingConnection(canvas))
return;
const [x, y] = getLayerForgeGraphPosition(canvas, event);
const hit = hitTestLayerForgeVirtualLinks(canvas.graph || app.graph, x, y);
if (!hit)
return;
openLayerForgeVirtualLinkMenu(canvas, hit, event);
event.preventDefault?.();
event.stopPropagation?.();
event.stopImmediatePropagation?.();
};
canvas.canvas?.addEventListener?.('pointerdown', linkPointerHandler, true);
};
const convertLayerForgeImageConnection = (node, inputIndex, linkInfo = null) => {
if (!node || node.__layerForgeVirtualWireClearing)
return false;
const input = node.inputs?.[inputIndex];
if (!input || (String(input.name || '') !== 'input_image' && !isLayerForgeTransportInput(input.name))) {
return false;
}
const graph = node.graph || app.graph;
const linkId = input.link ?? linkInfo?.id ?? linkInfo?.link_id ?? linkInfo?.linkId;
const nativeLink = getLayerForgeGraphLink(node, linkId) || linkInfo;
if (!nativeLink)
return false;
const sourceId = Number(nativeLink.origin_id ?? nativeLink.originId ?? nativeLink.from_id ?? nativeLink.fromId);
const sourceSlot = Number(nativeLink.origin_slot ?? nativeLink.originSlot ?? nativeLink.from_slot ?? nativeLink.fromSlot ?? 0);
if (!Number.isFinite(sourceId) || !Number.isFinite(sourceSlot) || sourceId === Number(node.id))
return false;
const sourceNode = graph?.getNodeById?.(sourceId);
const sourceType = sourceNode?.outputs?.[sourceSlot]?.type
|| nativeLink.type
|| 'IMAGE';
addLayerForgeImageInputLink(node, {
source_id: sourceId,
source_slot: sourceSlot,
source_type: String(sourceType),
});
node.__layerForgeVirtualWireClearing = true;
try {
if (input.link != null && typeof node.disconnectInput === 'function') {
node.disconnectInput(inputIndex);
}
else if (linkId != null && typeof graph?.removeLink === 'function') {
graph.removeLink(linkId);
}
if (node.inputs?.[inputIndex])
node.inputs[inputIndex].link = null;
}
finally {
node.__layerForgeVirtualWireClearing = false;
}
node.setDirtyCanvas?.(true, true);
graph?.setDirtyCanvas?.(true, true);
app.graph?.change?.();
return true;
};
const scheduleLayerForgeImageConnectionConversion = (node, inputIndex, linkInfo = null) => {
setTimeout(() => convertLayerForgeImageConnection(node, inputIndex, linkInfo), 0);
if (!linkInfo)
setTimeout(() => convertLayerForgeImageConnection(node, inputIndex), 50);
};
const pruneLayerForgeTransportInputs = (node) => {
if (!Array.isArray(node?.inputs))
return;
for (let index = node.inputs.length - 1; index >= 0; index -= 1) {
const input = node.inputs[index];
if (!isLayerForgeTransportInput(input?.name))
continue;
if (input.link != null)
convertLayerForgeImageConnection(node, index);
if (input.link != null)
continue;
if (typeof node.removeInput === 'function')
node.removeInput(index);
else
node.inputs.splice(index, 1);
}
};
const installLayerForgeMultiImagePromptPatch = () => {
const appWithPrompt = app;
const originalGraphToPrompt = appWithPrompt.graphToPrompt;
if (typeof originalGraphToPrompt !== 'function' || originalGraphToPrompt.__layerForgeMultiImagePatched)
return;
const graphToPrompt = async function (...args) {
const promptData = await originalGraphToPrompt.apply(this, args);
const output = promptData?.output;
if (!output)
return promptData;
for (const node of app.graph?._nodes || []) {
if (node?.comfyClass !== 'LayerForgeNode' && node?.type !== 'LayerForgeNode')
continue;
const promptNode = output[String(node.id)];
if (!promptNode)
continue;
promptNode.inputs || (promptNode.inputs = {});
for (let index = 1; index <= LAYERFORGE_MAX_IMAGE_INPUTS; index += 1) {
delete promptNode.inputs[`input_image_${index}`];
}
const links = getLayerForgeImageInputLinks(node)
.filter(link => output[String(link.source_id)]);
if (links.length === 0)
continue;
delete promptNode.inputs.input_image;
links.forEach((link, index) => {
promptNode.inputs[`input_image_${index + 1}`] = [String(link.source_id), link.source_slot];
});
}
return promptData;
};
graphToPrompt.__layerForgeMultiImagePatched = true;
appWithPrompt.graphToPrompt = graphToPrompt;
};
app.registerExtension({
name: "Comfy.LayerForgeNode",
init() {
addStylesheet(getUrl('./css/canvas_view.css'));
installLayerForgeMultiImagePromptPatch();
installLayerForgeVirtualWirePatch();
for (const delay of [0, 100, 500, 1200]) {
setTimeout(installLayerForgeVirtualWirePatch, delay);
}
const originalQueuePrompt = app.queuePrompt;
app.queuePrompt = async function (number, prompt) {
installLayerForgeMultiImagePromptPatch();
installLayerForgeVirtualWirePatch();
log.info("Preparing to queue prompt...");
if (canvasNodeInstances.size > 0) {
log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`);
@@ -1707,6 +2502,7 @@ app.registerExtension({
const pendingCopySources = new Map();
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
var _a;
log.debug("CanvasNode onNodeCreated: Base widget setup.");
const r = onNodeCreated?.apply(this, arguments);
const nodeWithPreviewHook = this;
@@ -1721,6 +2517,8 @@ app.registerExtension({
};
nodeWithPreviewHook.__layerForgePreviewWidgetHooked = true;
}
(_a = this).properties || (_a.properties = {});
pruneLayerForgeTransportInputs(this);
this.size = [1150, 1000];
return r;
};
@@ -1789,8 +2587,9 @@ app.registerExtension({
// Check if there are already connected inputs
setTimeout(() => {
if (this.inputs && this.inputs.length > 0) {
// Check if input_image (index 0) is connected
if (this.inputs[0] && this.inputs[0].link) {
// Check if input_image is connected, including virtual
// links restored from a saved workflow.
if (hasLayerForgeImageInput(this)) {
log.info("Input image already connected on node creation, checking for data...");
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
canvasWidget.canvas.inputDataLoaded = false;
@@ -1807,8 +2606,16 @@ app.registerExtension({
// Add onConnectionsChange handler to detect when inputs are connected
nodeType.prototype.onConnectionsChange = function (type, index, connected, link_info) {
log.info(`onConnectionsChange called: type=${type}, index=${index}, connected=${connected}`, link_info);
if (this.__layerForgeVirtualWireClearing)
return;
// Check if this is an input connection (type 1 = INPUT)
if (type === 1) {
const inputName = String(this.inputs?.[index]?.name || '');
const isImageInput = inputName === 'input_image' || isLayerForgeTransportInput(inputName) || index === 0;
const isMaskInput = inputName === 'input_mask' || index === 1;
if (connected && link_info && (inputName === 'input_image' || isLayerForgeTransportInput(inputName))) {
scheduleLayerForgeImageConnectionConversion(this, index, link_info);
}
// Get the canvas widget - it might be in different places
const canvasWidget = this.canvasWidget;
const canvas = canvasWidget?.canvas || canvasWidget;
@@ -1824,7 +2631,7 @@ app.registerExtension({
if (connected) {
retryCanvas.inputDataLoaded = false;
// Respect which input triggered the connection:
const opts = (index === 1)
const opts = isMaskInput
? { allowImage: false, allowMask: true, reason: "mask_connect" }
: { allowImage: true, allowMask: false, reason: "image_connect" };
retryCanvas.canvasIO.checkForInputData(opts);
@@ -1841,8 +2648,8 @@ app.registerExtension({
setTimeout(tryAgain, retryDelays[retryCount++]);
return;
}
// Handle input_image connection (index 0)
if (index === 0) {
// Handle input_image connection (including the virtual multi-link port)
if (isImageInput) {
if (connected && link_info) {
log.info("Input image connected, marking for data check...");
// Reset the input data loaded flag to allow loading the new connection
@@ -1853,7 +2660,7 @@ app.registerExtension({
// Mark that we have a pending input connection
canvas.hasPendingInputConnection = true;
// If mask input is not connected and a mask was auto-applied from input_mask before, clear it now
if (!(this.inputs && this.inputs[1] && this.inputs[1].link)) {
if (getLayerForgeMaskInputSlot(this)?.link == null) {
if (canvas.maskAppliedFromInput && canvas.maskTool) {
canvas.maskTool.clear();
canvas.render();
@@ -1879,7 +2686,7 @@ app.registerExtension({
}
}
// Handle input_mask connection (index 1)
if (index === 1) {
if (isMaskInput) {
if (connected && link_info) {
log.info("Input mask connected");
// DON'T clear existing mask when connecting a new input
@@ -1978,9 +2785,12 @@ app.registerExtension({
// Handle copy/paste - load canvas state from source node when pasting
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = async function (data) {
var _a;
if (originalConfigure) {
originalConfigure.apply(this, [data]);
}
(_a = this).properties || (_a.properties = {});
pruneLayerForgeTransportInputs(this);
// Store the source node ID in the map (persists across node ID changes)
// This will be picked up later in onAdded when the canvas widget is ready
if (data.sourceNodeId && data.sourceNodeId !== this.id) {
@@ -2087,7 +2897,24 @@ app.registerExtension({
}
}
};
const virtualImageLinks = getLayerForgeImageInputLinks(self);
const newOptions = [
...(virtualImageLinks.length > 0 ? [{
content: `Clear ${virtualImageLinks.length} connected input image${virtualImageLinks.length === 1 ? '' : 's'}`,
callback: () => {
const cleared = clearLayerForgeImageInputLinks(self);
if (!cleared)
return;
self.setDirtyCanvas?.(true, true);
app.graph?.change?.();
const canvas = self.canvasWidget?.canvas;
if (canvas) {
canvas.inputDataLoaded = false;
canvas.lastLoadedImageSrc = undefined;
canvas.lastLoadedLinkId = undefined;
}
},
}] : []),
{
content: "Open in MaskEditor",
callback: async () => {
+84
View File
@@ -0,0 +1,84 @@
export const LAYERFORGE_IMAGE_LINKS_PROPERTY = 'layerforge_input_image_links';
export const LAYERFORGE_MAX_IMAGE_INPUTS = 32;
export function getLayerForgeImageInputLinks(node) {
const rawLinks = node?.properties?.[LAYERFORGE_IMAGE_LINKS_PROPERTY];
if (!Array.isArray(rawLinks))
return [];
const links = [];
const seen = new Set();
for (const rawLink of rawLinks) {
const sourceId = Number(rawLink?.source_id);
const sourceSlot = Number(rawLink?.source_slot ?? 0);
if (!Number.isFinite(sourceId) || !Number.isFinite(sourceSlot))
continue;
const normalizedSlot = Math.max(0, Math.trunc(sourceSlot));
const key = `${sourceId}:${normalizedSlot}`;
if (seen.has(key))
continue;
seen.add(key);
links.push({
source_id: sourceId,
source_slot: normalizedSlot,
source_type: String(rawLink?.source_type || 'IMAGE'),
});
if (links.length >= LAYERFORGE_MAX_IMAGE_INPUTS)
break;
}
return links;
}
export function setLayerForgeImageInputLinks(node, links) {
node.properties || (node.properties = {});
node.properties[LAYERFORGE_IMAGE_LINKS_PROPERTY] = links
.slice(0, LAYERFORGE_MAX_IMAGE_INPUTS)
.map(link => ({
source_id: Number(link.source_id),
source_slot: Math.max(0, Math.trunc(Number(link.source_slot) || 0)),
source_type: String(link.source_type || 'IMAGE'),
}));
}
export function addLayerForgeImageInputLink(node, link) {
const links = getLayerForgeImageInputLinks(node);
const sourceId = Number(link.source_id);
const sourceSlot = Math.max(0, Math.trunc(Number(link.source_slot) || 0));
if (!Number.isFinite(sourceId) || links.length >= LAYERFORGE_MAX_IMAGE_INPUTS)
return false;
if (links.some(existing => existing.source_id === sourceId && existing.source_slot === sourceSlot)) {
return false;
}
links.push({
source_id: sourceId,
source_slot: sourceSlot,
source_type: String(link.source_type || 'IMAGE'),
});
setLayerForgeImageInputLinks(node, links);
return true;
}
export function removeLayerForgeImageInputLink(node, index) {
const links = getLayerForgeImageInputLinks(node);
if (!Number.isInteger(index) || index < 0 || index >= links.length)
return false;
links.splice(index, 1);
setLayerForgeImageInputLinks(node, links);
return true;
}
export function clearLayerForgeImageInputLinks(node) {
const links = getLayerForgeImageInputLinks(node);
if (links.length === 0)
return 0;
setLayerForgeImageInputLinks(node, []);
return links.length;
}
export function getLayerForgeImageInputSlot(node) {
return node?.inputs?.find((input) => input?.name === 'input_image')
?? node?.inputs?.[0]
?? null;
}
export function getLayerForgeMaskInputSlot(node) {
return node?.inputs?.find((input) => input?.name === 'input_mask')
?? node?.inputs?.[1]
?? null;
}
export function hasLayerForgeImageInput(node) {
return getLayerForgeImageInputLinks(node).length > 0
|| getLayerForgeImageInputSlot(node)?.link != null;
}
+45 -5
View File
@@ -16,6 +16,7 @@ from PIL import Image
from .image_serialization import data_url_to_pil, pil_to_data_url
_OUTPUT_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".gif")
_MAX_IMAGE_INPUTS = 32
try:
from .log_system import create_module_logger
@@ -136,6 +137,13 @@ class LayerForgeNode:
@classmethod
def INPUT_TYPES(cls):
optional = {
"input_image": ("IMAGE",),
"input_mask": ("MASK",),
}
for index in range(1, _MAX_IMAGE_INPUTS + 1):
optional[f"input_image_{index}"] = ("IMAGE", {"hidden": True})
return {
"required": {
"fit_on_add": (
@@ -157,10 +165,7 @@ class LayerForgeNode:
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
"node_id": ("STRING", {"default": "0"}),
},
"optional": {
"input_image": ("IMAGE",),
"input_mask": ("MASK",),
},
"optional": optional,
"hidden": {
"prompt": ("PROMPT",),
"unique_id": ("UNIQUE_ID",),
@@ -234,6 +239,7 @@ class LayerForgeNode:
input_mask=None,
prompt=None,
unique_id=None,
**kwargs,
):
del show_preview, auto_refresh_after_generation, trigger, prompt
@@ -254,7 +260,41 @@ class LayerForgeNode:
with self.__class__._storage_lock:
input_data = {}
if input_image is not None and isinstance(input_image, torch.Tensor):
transport_images = sorted(
[
(name, value)
for name, value in kwargs.items()
if name.startswith("input_image_")
and name[len("input_image_"):].isdigit()
and isinstance(value, torch.Tensor)
],
key=lambda item: int(item[0][len("input_image_"):]),
)
if transport_images:
image_sources = []
if isinstance(input_image, torch.Tensor):
image_sources.append(("input_image", input_image))
image_sources.extend(transport_images)
images_array = []
for source_name, source_tensor in image_sources:
tensor = source_tensor.unsqueeze(0) if source_tensor.dim() == 3 else source_tensor
if tensor.dim() != 4:
log.warning(
f"Skipping {source_name}: expected an IMAGE tensor with 3 or 4 dimensions, "
f"got {tuple(source_tensor.shape)}"
)
continue
for index in range(tensor.shape[0]):
serialized_image = self._serialize_rgb_tensor_sample(tensor[index])
images_array.append(serialized_image)
if images_array:
input_data["input_images"] = images_array
log.info(f"Stored {len(images_array)} image(s) from multiple image inputs")
elif input_image is not None and isinstance(input_image, torch.Tensor):
if input_image.dim() == 3:
input_image = input_image.unsqueeze(0)
+2 -2
View File
@@ -124,8 +124,8 @@ export class Canvas {
pendingInputDataCheck: number | null;
inputDataLoaded: boolean;
lastLoadedImageSrc?: string;
lastLoadedLinkId?: number;
lastLoadedMaskLinkId?: number;
lastLoadedLinkId?: string | number;
lastLoadedMaskLinkId?: string | number;
previewVisible: boolean;
requestSaveState: () => void;
viewport: Viewport;
+173 -111
View File
@@ -5,26 +5,43 @@ import { webSocketManager } from "./utils/WebSocketManager.js";
import { scaleImageToFit, loadImage, blobToDataUrl, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
import { postImageBlob } from "./utils/ImageUploadUtils.js";
import { getImageAddMode, isFitOnAddEnabled } from "./utils/CanvasInputUtils.js";
import {
getLayerForgeImageInputLinks,
getLayerForgeImageInputSlot,
getLayerForgeMaskInputSlot,
hasLayerForgeImageInput,
} from "./utils/MultiImageInputUtils.js";
import type { Canvas } from './Canvas';
import type { Layer, Shape, AddMode } from './types';
const log = createModuleLogger('CanvasIO');
interface BackendInputData {
input_images?: Array<{ data: string }>;
input_images_batch?: Array<{ data: string }>;
input_image?: string;
}
interface ConnectedImageSource {
sourceNode: any;
sourceId: number;
sourceSlot: number;
}
function imageBatchIdentity(sources: readonly string[]): string {
return sources.join('|');
}
function getBackendImageIdentity(data?: BackendInputData): string | undefined {
if (Array.isArray(data?.input_images_batch)) {
return imageBatchIdentity(data.input_images_batch.map(image => image.data));
}
function getBackendImageSources(data?: BackendInputData): Array<{ data: string }> {
if (Array.isArray(data?.input_images)) return data.input_images;
if (Array.isArray(data?.input_images_batch)) return data.input_images_batch;
if (data?.input_image) return [{ data: data.input_image }];
return [];
}
return data?.input_image;
function getBackendImageIdentity(data?: BackendInputData): string | undefined {
const sources = getBackendImageSources(data);
return sources.length > 0 ? imageBatchIdentity(sources.map(image => image.data)) : undefined;
}
export class CanvasIO {
@@ -36,6 +53,76 @@ export class CanvasIO {
this._saveInProgress = null;
}
private getImageInputSlot(): any | null {
return getLayerForgeImageInputSlot(this.canvas.node);
}
private getMaskInputSlot(): any | null {
return getLayerForgeMaskInputSlot(this.canvas.node);
}
private getGraphLink(linkId: any): any | null {
const graph = (this.canvas.node as any).graph;
if (!graph || linkId == null) return null;
for (const links of [graph.links, graph._links]) {
if (!links) continue;
if (typeof links.get === 'function') {
const link = links.get(linkId) ?? links.get(String(linkId));
if (link) return link;
}
const link = links[linkId] ?? links[String(linkId)];
if (link) return link;
}
return null;
}
private getImageInputIdentity(): string | number | undefined {
const virtualLinks = getLayerForgeImageInputLinks(this.canvas.node);
if (virtualLinks.length > 0) {
return `virtual:${virtualLinks.map(link => `${link.source_id}:${link.source_slot}`).join('|')}`;
}
return this.getImageInputSlot()?.link;
}
private getConnectedImageSources(): ConnectedImageSource[] {
const graph = (this.canvas.node as any).graph;
if (!graph) return [];
const sources: ConnectedImageSource[] = [];
const seen = new Set<string>();
const addSource = (sourceId: number, sourceSlot: number): void => {
const sourceNode = graph.getNodeById?.(sourceId);
if (!sourceNode?.imgs?.length) return;
const key = `${sourceId}:${sourceSlot}`;
if (seen.has(key)) return;
seen.add(key);
sources.push({ sourceNode, sourceId, sourceSlot });
};
for (const link of getLayerForgeImageInputLinks(this.canvas.node)) {
addSource(link.source_id, link.source_slot);
}
const nativeLinkId = this.getImageInputSlot()?.link;
const nativeLink = this.getGraphLink(nativeLinkId);
if (nativeLink) {
const sourceId = Number(nativeLink.origin_id ?? nativeLink.originId);
const sourceSlot = Number(nativeLink.origin_slot ?? nativeLink.originSlot ?? 0);
if (Number.isFinite(sourceId)) addSource(sourceId, Number.isFinite(sourceSlot) ? sourceSlot : 0);
}
return sources;
}
private hasImageInput(): boolean {
return hasLayerForgeImageInput(this.canvas.node);
}
private async addBatchImages(
images: readonly (HTMLImageElement | string)[],
addMode: AddMode,
@@ -409,17 +496,20 @@ export class CanvasIO {
return this.scheduleDataCheck();
}
if ((this.canvas.node as any).inputs[0] && (this.canvas.node as any).inputs[0].link) {
const imageLinkId = (this.canvas.node as any).inputs[0].link;
const imageInput = this.getImageInputSlot();
if (this.hasImageInput()) {
const imageLinkId = imageInput?.link;
// Check if we already loaded this link
if (this.canvas.lastLoadedLinkId === imageLinkId) {
if (imageLinkId != null && this.canvas.lastLoadedLinkId === imageLinkId) {
log.debug(`Link ${imageLinkId} already loaded via new system, marking as initialized`);
this.canvas.dataInitialized = true;
return;
}
const imageData = (window as any).app.nodeOutputs[imageLinkId];
const imageData = imageLinkId != null
? (window as any).app.nodeOutputs[imageLinkId]
: undefined;
if (imageData) {
log.debug("Found image data:", imageData);
@@ -434,8 +524,9 @@ export class CanvasIO {
this.canvas.dataInitialized = true;
}
if ((this.canvas.node as any).inputs[1] && (this.canvas.node as any).inputs[1].link) {
const maskLinkId = (this.canvas.node as any).inputs[1].link;
const maskInput = this.getMaskInputSlot();
if (maskInput?.link != null) {
const maskLinkId = maskInput.link;
const maskData = (window as any).app.nodeOutputs[maskLinkId];
if (maskData) {
@@ -463,105 +554,75 @@ export class CanvasIO {
let maskLoaded = false;
let imageChanged = false;
// First, try to get data from connected node's output if available (IMAGES)
if (allowImage && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const linkId = this.canvas.node.inputs[0].link;
const graph = (this.canvas.node as any).graph;
// Always check if images have changed first
if (graph) {
const link = graph.links[linkId];
if (link) {
const sourceNode = graph.getNodeById(link.origin_id);
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
// Create current batch identifier (all image sources combined)
const currentBatchImageSrcs = imageBatchIdentity(sourceNode.imgs.map((img: HTMLImageElement) => img.src));
// Check if this is the same link we loaded before
if (this.canvas.lastLoadedLinkId === linkId) {
// Same link, check if images actually changed
if (this.canvas.lastLoadedImageSrc !== currentBatchImageSrcs) {
log.info(`Batch images changed for link ${linkId} (${sourceNode.imgs.length} images), will reload...`);
log.debug(`Previous batch hash: ${this.canvas.lastLoadedImageSrc?.substring(0, 100)}...`);
log.debug(`Current batch hash: ${currentBatchImageSrcs.substring(0, 100)}...`);
imageChanged = true;
// Clear the inputDataLoaded flag to force reload from backend
this.canvas.inputDataLoaded = false;
// Clear the lastLoadedImageSrc to force reload
this.canvas.lastLoadedImageSrc = undefined;
// Clear backend data to force fresh load
fetch(`/layerforge/clear_input_data/${nodeId}`, { method: 'POST' })
.then(() => log.debug("Backend input data cleared due to image change"))
.catch(err => log.error("Failed to clear backend data:", err));
} else {
log.debug(`Batch images for link ${linkId} unchanged (${sourceNode.imgs.length} images)`);
imageLoaded = true;
}
} else {
// Different link or first load
log.info(`New link ${linkId} detected, will load ${sourceNode.imgs.length} images`);
imageChanged = false; // It's not a change, it's a new link
imageLoaded = false; // Need to load
// Reset the inputDataLoaded flag for new link
this.canvas.inputDataLoaded = false;
}
}
// First, try to get data from every connected image source. The visible
// input can have several virtual links, while legacy native links remain
// supported as a fallback.
const connectedImageSources = this.getConnectedImageSources();
if (allowImage && connectedImageSources.length > 0) {
const imageInputIdentity = this.getImageInputIdentity() ?? 'image-input';
const currentSourceIdentities = connectedImageSources.map(({ sourceNode }) => imageBatchIdentity(sourceNode.imgs.map((img: HTMLImageElement) => img.src)));
const currentBatchImageSrcs = imageBatchIdentity(currentSourceIdentities);
if (this.canvas.lastLoadedLinkId === imageInputIdentity) {
if (this.canvas.lastLoadedImageSrc !== currentBatchImageSrcs) {
log.info(`Connected input images changed (${connectedImageSources.length} source(s)), will reload...`);
log.debug(`Previous image hash: ${this.canvas.lastLoadedImageSrc?.substring(0, 100)}...`);
log.debug(`Current image hash: ${currentBatchImageSrcs.substring(0, 100)}...`);
imageChanged = true;
this.canvas.inputDataLoaded = false;
this.canvas.lastLoadedImageSrc = undefined;
fetch(`/layerforge/clear_input_data/${nodeId}`, { method: 'POST' })
.then(() => log.debug("Backend input data cleared due to image change"))
.catch(err => log.error("Failed to clear backend data:", err));
} else {
log.debug(`Connected input images unchanged (${connectedImageSources.length} source(s))`);
imageLoaded = true;
}
} else {
log.info(`New image input connection set detected, will load ${connectedImageSources.length} source(s)`);
imageChanged = false;
imageLoaded = false;
this.canvas.inputDataLoaded = false;
}
if (!imageLoaded || imageChanged) {
// Reset the inputDataLoaded flag when images change
if (imageChanged) {
this.canvas.inputDataLoaded = false;
log.info("Resetting inputDataLoaded flag due to image change");
}
if ((this.canvas.node as any).graph) {
const graph2 = (this.canvas.node as any).graph;
const link2 = graph2.links[linkId];
if (link2) {
const sourceNode = graph2.getNodeById(link2.origin_id);
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
// The connected node has images in its output - handle multiple images (batch)
log.info(`Found ${sourceNode.imgs.length} image(s) in connected node's output, loading all`);
// Create a combined source identifier for batch detection
const batchImageSrcs = imageBatchIdentity(sourceNode.imgs.map((img: HTMLImageElement) => img.src));
// Mark this link and batch sources as loaded
this.canvas.lastLoadedLinkId = linkId;
this.canvas.lastLoadedImageSrc = batchImageSrcs;
// Don't clear layers - just add new ones
if (imageChanged) {
log.info("Image change detected, will add new layers");
}
// Determine add mode
const addMode = getImageAddMode(this.canvas.node.widgets);
// Add all images from the batch as separate layers
await this.addBatchImages(
sourceNode.imgs,
addMode,
this.canvas.outputAreaBounds,
'to canvas'
);
this.canvas.inputDataLoaded = true;
imageLoaded = true;
log.info(`All ${sourceNode.imgs.length} input images from batch added as separate layers`);
this.canvas.render();
this.canvas.saveState();
}
}
const sourceImages = connectedImageSources.flatMap(({ sourceNode }) => sourceNode.imgs as HTMLImageElement[]);
if (sourceImages.length > 0) {
log.info(`Found ${sourceImages.length} image(s) across ${connectedImageSources.length} connected source(s), loading all`);
const sourceIdentities = connectedImageSources.map(({ sourceNode }) => imageBatchIdentity(sourceNode.imgs.map((img: HTMLImageElement) => img.src)));
const batchImageSrcs = imageBatchIdentity(sourceIdentities);
this.canvas.lastLoadedLinkId = imageInputIdentity;
this.canvas.lastLoadedImageSrc = batchImageSrcs;
if (imageChanged) log.info("Image change detected, will add new layers");
const addMode = getImageAddMode(this.canvas.node.widgets);
await this.addBatchImages(
sourceImages,
addMode,
this.canvas.outputAreaBounds,
'to canvas'
);
this.canvas.inputDataLoaded = true;
imageLoaded = true;
log.info(`All ${sourceImages.length} connected input images added as separate layers`);
this.canvas.render();
this.canvas.saveState();
}
}
}
// Check for mask input separately (from nodeOutputs) ONLY when allowed
if (allowMask && this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
const maskLinkId = this.canvas.node.inputs[1].link;
const maskInput = this.getMaskInputSlot();
if (allowMask && maskInput?.link != null) {
const maskLinkId = maskInput.link;
// Check if we already loaded this mask link
if (this.canvas.lastLoadedMaskLinkId === maskLinkId) {
@@ -573,7 +634,7 @@ export class CanvasIO {
let maskOutput = null;
if (graph) {
const link = graph.links[maskLinkId];
const link = this.getGraphLink(maskLinkId);
if (link && link.origin_id) {
// Use origin_id to get the actual node output
const nodeOutput = (window as any).app?.nodeOutputs?.[link.origin_id];
@@ -670,8 +731,8 @@ export class CanvasIO {
}
// Only check backend if we have actual inputs connected
const hasImageInput = this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link;
const hasMaskInput = this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link;
const hasImageInput = this.hasImageInput();
const hasMaskInput = this.getMaskInputSlot()?.link != null;
// If mask input is disconnected, clear any currently applied mask to ensure full separation
if (!hasMaskInput) {
@@ -712,8 +773,8 @@ export class CanvasIO {
}
// Check if we already loaded image data (by checking the current link)
if (allowImage && !imageLoaded && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const currentLinkId = this.canvas.node.inputs[0].link;
if (allowImage && !imageLoaded && hasImageInput) {
const currentLinkId = this.getImageInputIdentity();
if (this.canvas.lastLoadedLinkId !== currentLinkId) {
// Mark this link as loaded
this.canvas.lastLoadedLinkId = currentLinkId;
@@ -723,8 +784,8 @@ export class CanvasIO {
// Check for mask data from backend ONLY when mask input is actually connected AND allowed
// Only reset if the mask link actually changed
if (allowMask && hasMaskInput && this.canvas.node.inputs && this.canvas.node.inputs[1]) {
const currentMaskLinkId = this.canvas.node.inputs[1].link;
if (allowMask && hasMaskInput) {
const currentMaskLinkId = this.getMaskInputSlot()?.link;
// Only reset if this is a different mask link than what we loaded before
if (this.canvas.lastLoadedMaskLinkId !== currentMaskLinkId) {
maskLoaded = false;
@@ -763,10 +824,11 @@ export class CanvasIO {
// Load input image(s) only if image input is actually connected, not already loaded, and allowed
if (allowImage && !imageLoaded && hasImageInput) {
if (inputData.input_images_batch) {
// Handle batch of images
const batch = inputData.input_images_batch;
log.info(`Processing batch of ${batch.length} images from backend`);
if (inputData.input_images || inputData.input_images_batch) {
// Handle ordered images from multiple inputs, while retaining
// the legacy tensor-batch payload for older workflows.
const batch = getBackendImageSources(inputData);
log.info(`Processing ${batch.length} ordered input images from backend`);
await this.addBatchImages(
batch.map((imgData: { data: string }) => imgData.data),
@@ -775,7 +837,7 @@ export class CanvasIO {
'from backend'
);
log.info(`All ${batch.length} batch images added from backend`);
log.info(`All ${batch.length} input images added from backend`);
this.canvas.render();
this.canvas.saveState();
@@ -797,7 +859,7 @@ export class CanvasIO {
} else {
log.debug("No input image data from backend");
}
} else if (!hasImageInput && (inputData.input_images_batch || inputData.input_image)) {
} else if (!hasImageInput && (inputData.input_images || inputData.input_images_batch || inputData.input_image)) {
log.debug("Backend has image data but no image input connected, skipping image load");
}
+931 -7
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -49,7 +49,7 @@ export interface ComfyNode {
graph?: any;
onRemoved?: () => void;
addDOMWidget?: (name: string, type: string, element: HTMLElement) => void;
inputs?: Array<{ link: any }>;
inputs?: Array<{ link: any; name?: string; type?: string }>;
}
declare global {
+104
View File
@@ -0,0 +1,104 @@
export const LAYERFORGE_IMAGE_LINKS_PROPERTY = 'layerforge_input_image_links';
export const LAYERFORGE_MAX_IMAGE_INPUTS = 32;
export interface LayerForgeImageInputLink {
source_id: number;
source_slot: number;
source_type?: string;
}
export function getLayerForgeImageInputLinks(node: any): LayerForgeImageInputLink[] {
const rawLinks = node?.properties?.[LAYERFORGE_IMAGE_LINKS_PROPERTY];
if (!Array.isArray(rawLinks)) return [];
const links: LayerForgeImageInputLink[] = [];
const seen = new Set<string>();
for (const rawLink of rawLinks) {
const sourceId = Number(rawLink?.source_id);
const sourceSlot = Number(rawLink?.source_slot ?? 0);
if (!Number.isFinite(sourceId) || !Number.isFinite(sourceSlot)) continue;
const normalizedSlot = Math.max(0, Math.trunc(sourceSlot));
const key = `${sourceId}:${normalizedSlot}`;
if (seen.has(key)) continue;
seen.add(key);
links.push({
source_id: sourceId,
source_slot: normalizedSlot,
source_type: String(rawLink?.source_type || 'IMAGE'),
});
if (links.length >= LAYERFORGE_MAX_IMAGE_INPUTS) break;
}
return links;
}
export function setLayerForgeImageInputLinks(node: any, links: LayerForgeImageInputLink[]): void {
node.properties ||= {};
node.properties[LAYERFORGE_IMAGE_LINKS_PROPERTY] = links
.slice(0, LAYERFORGE_MAX_IMAGE_INPUTS)
.map(link => ({
source_id: Number(link.source_id),
source_slot: Math.max(0, Math.trunc(Number(link.source_slot) || 0)),
source_type: String(link.source_type || 'IMAGE'),
}));
}
export function addLayerForgeImageInputLink(
node: any,
link: LayerForgeImageInputLink,
): boolean {
const links = getLayerForgeImageInputLinks(node);
const sourceId = Number(link.source_id);
const sourceSlot = Math.max(0, Math.trunc(Number(link.source_slot) || 0));
if (!Number.isFinite(sourceId) || links.length >= LAYERFORGE_MAX_IMAGE_INPUTS) return false;
if (links.some(existing => existing.source_id === sourceId && existing.source_slot === sourceSlot)) {
return false;
}
links.push({
source_id: sourceId,
source_slot: sourceSlot,
source_type: String(link.source_type || 'IMAGE'),
});
setLayerForgeImageInputLinks(node, links);
return true;
}
export function removeLayerForgeImageInputLink(node: any, index: number): boolean {
const links = getLayerForgeImageInputLinks(node);
if (!Number.isInteger(index) || index < 0 || index >= links.length) return false;
links.splice(index, 1);
setLayerForgeImageInputLinks(node, links);
return true;
}
export function clearLayerForgeImageInputLinks(node: any): number {
const links = getLayerForgeImageInputLinks(node);
if (links.length === 0) return 0;
setLayerForgeImageInputLinks(node, []);
return links.length;
}
export function getLayerForgeImageInputSlot(node: any): any | null {
return node?.inputs?.find((input: any) => input?.name === 'input_image')
?? node?.inputs?.[0]
?? null;
}
export function getLayerForgeMaskInputSlot(node: any): any | null {
return node?.inputs?.find((input: any) => input?.name === 'input_mask')
?? node?.inputs?.[1]
?? null;
}
export function hasLayerForgeImageInput(node: any): boolean {
return getLayerForgeImageInputLinks(node).length > 0
|| getLayerForgeImageInputSlot(node)?.link != null;
}
+52 -1
View File
@@ -158,7 +158,15 @@ def test_entrypoint_exports_node_contract_and_frontend_directory(layerforge_runt
"trigger",
"node_id",
} <= set(inputs["required"])
assert inputs["optional"] == {"input_image": ("IMAGE",), "input_mask": ("MASK",)}
assert inputs["optional"]["input_image"] == ("IMAGE",)
assert inputs["optional"]["input_mask"] == ("MASK",)
transport_inputs = {
name: value
for name, value in inputs["optional"].items()
if name.startswith("input_image_")
}
assert len(transport_inputs) == 32
assert all(value == ("IMAGE", {"hidden": True}) for value in transport_inputs.values())
def test_entrypoint_registers_backend_route_contract(layerforge_runtime):
@@ -294,6 +302,49 @@ def test_tensor_input_payloads_preserve_single_and_batch_image_contract(layerfor
assert all(item["data"].startswith("data:image/png;base64,") for item in batch_payload["input_images_batch"])
def test_tensor_input_payloads_preserve_ordered_multiple_image_inputs(layerforge_runtime):
import torch
from PIL import Image
node_class = layerforge_runtime.node.LayerForgeNode
node_class._canvas_data_storage.clear()
node_class._canvas_cache["persistent_cache"] = {}
node = node_class()
red = torch.tensor(
[[[
[1.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
]]],
dtype=torch.float32,
)
green = red.clone()
green[..., 0] = 0
green[..., 1] = 1
# Deliberately pass the transport inputs out of numeric order. The node
# must preserve the port order used by the virtual multi-input frontend.
node.process_canvas_image(
False,
False,
False,
0,
"multi-input",
input_image_2=green,
input_image_1=red,
)
payload = node_class._canvas_data_storage["multi-input_input"]
assert "input_image" not in payload
assert len(payload["input_images"]) == 2
decoded = [
Image.open(io.BytesIO(base64.b64decode(item["data"].split(",", 1)[1])))
for item in payload["input_images"]
]
assert decoded[0].getpixel((0, 0)) == (255, 0, 0)
assert decoded[1].getpixel((0, 0)) == (0, 255, 0)
def test_latest_image_helpers_preserve_filtering_and_time_semantics(layerforge_runtime, monkeypatch, tmp_path):
node_module = layerforge_runtime.node
node_class = node_module.LayerForgeNode
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const [utilitySource, canvasViewSource] = await Promise.all([
readFile(new URL('../src/utils/MultiImageInputUtils.ts', import.meta.url), 'utf8'),
readFile(new URL('../src/CanvasView.ts', import.meta.url), 'utf8'),
]);
test('LayerForge exposes an ordered virtual multi-image input contract', () => {
assert.match(utilitySource, /LAYERFORGE_IMAGE_LINKS_PROPERTY = 'layerforge_input_image_links'/);
assert.match(utilitySource, /LAYERFORGE_MAX_IMAGE_INPUTS = 32/);
assert.match(utilitySource, /addLayerForgeImageInputLink/);
assert.match(utilitySource, /getLayerForgeImageInputSlot/);
assert.match(canvasViewSource, /installLayerForgeMultiImagePromptPatch/);
assert.match(canvasViewSource, /input_image_\$\{index \+ 1\}/);
assert.match(canvasViewSource, /scheduleLayerForgeImageConnectionConversion/);
assert.match(canvasViewSource, /pruneLayerForgeTransportInputs/);
assert.match(canvasViewSource, /drawLayerForgeVirtualLinks/);
assert.match(canvasViewSource, /installLayerForgeVirtualWirePatch/);
assert.match(canvasViewSource, /bezierCurveTo/);
assert.match(canvasViewSource, /hitTestLayerForgeVirtualLinks/);
assert.match(canvasViewSource, /Remove connection/);
assert.match(canvasViewSource, /removeLayerForgeImageInputLink/);
assert.match(canvasViewSource, /installLayerForgeQuickCreateCapture/);
assert.match(canvasViewSource, /createLayerForgeLoadImageNode/);
assert.match(canvasViewSource, /content: 'Load image'/);
assert.match(canvasViewSource, /scheduleLayerForgeQuickCreateMenu/);
});
test('CanvasIO uses virtual source links for immediate canvas loading', () => {
assert.match(canvasViewSource, /hasLayerForgeImageInput\(this\)/);
assert.match(canvasViewSource, /canvasIO\.checkForInputData\(\{ allowImage: true, allowMask: false, reason: "image_connect" \}\)/);
});