Add ordered multi-image input support via virtual transport links
This commit is contained in:
+147
-97
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" \}\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user