From c8b9e3ccc42e056fc87ecc5adb673abfbc59f995 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 14 Aug 2026 04:25:08 +0200 Subject: [PATCH] Add ordered multi-image input support via virtual transport links --- js/CanvasIO.js | 244 +++-- js/CanvasView.js | 841 +++++++++++++++- js/utils/MultiImageInputUtils.js | 84 ++ python/node.py | 50 +- src/Canvas.ts | 4 +- src/CanvasIO.ts | 284 +++--- src/CanvasView.ts | 938 +++++++++++++++++- src/types.ts | 2 +- src/utils/MultiImageInputUtils.ts | 104 ++ tests/test_entrypoint_integration.py | 53 +- ...est_multi_image_input_characterization.mjs | 34 + 11 files changed, 2407 insertions(+), 231 deletions(-) create mode 100644 js/utils/MultiImageInputUtils.js create mode 100644 src/utils/MultiImageInputUtils.ts create mode 100644 tests/test_multi_image_input_characterization.mjs diff --git a/js/CanvasIO.js b/js/CanvasIO.js index f6a3458..0d6321e 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -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 diff --git a/js/CanvasView.js b/js/CanvasView.js index 3c228a7..78a6884 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -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 () => { diff --git a/js/utils/MultiImageInputUtils.js b/js/utils/MultiImageInputUtils.js new file mode 100644 index 0000000..0e781da --- /dev/null +++ b/js/utils/MultiImageInputUtils.js @@ -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; +} diff --git a/python/node.py b/python/node.py index baaadbf..9e91c2b 100644 --- a/python/node.py +++ b/python/node.py @@ -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) diff --git a/src/Canvas.ts b/src/Canvas.ts index 66cc456..c847a17 100644 --- a/src/Canvas.ts +++ b/src/Canvas.ts @@ -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; diff --git a/src/CanvasIO.ts b/src/CanvasIO.ts index 786fc5b..a05b4a8 100644 --- a/src/CanvasIO.ts +++ b/src/CanvasIO.ts @@ -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(); + 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"); } diff --git a/src/CanvasView.ts b/src/CanvasView.ts index 7ff0696..5853aef 100644 --- a/src/CanvasView.ts +++ b/src/CanvasView.ts @@ -23,6 +23,16 @@ import { exportCanvasImage, type CanvasExportAction } from "./utils/CanvasExport import { getFlattenedCanvasBlob, type CanvasBlobVariant } 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, @@ -1956,14 +1966,894 @@ $el("label.lf-clipboard-switch.lf-mask-switch", { const canvasNodeInstances = new Map(); +let layerForgeQuickCreateMenu: any = null; +let layerForgeQuickCreateCanvas: any = null; +let layerForgeQuickCreateCleanup: (() => void) | null = null; +let layerForgeQuickCreatePending = false; +let layerForgeQuickCreateToken = 0; +let layerForgeLastCapturedDropAt = 0; +let layerForgeSuppressNativeDropUntil = 0; + +const isLayerForgeTransportInput = (name: unknown): boolean => /^input_image_\d+$/i.test(String(name || "")); + +const getLayerForgeGraphLink = (node: any, linkId: any): any | null => { + 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: any[] | undefined, rawSlot: any): number => { + 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: any): { + direction: 'from_input' | 'from_output'; + targetNode?: any; + targetSlot?: number; + sourceNode?: any; + sourceSlot?: number; + sourceType?: string; +} | null => { + 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: 'from_input' | 'from_output' = 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: any, event: any): [number, number] => { + 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: any, isInput: boolean, slotIndex: number): [number, number] | null => { + const normalize = (point: any): [number, number] | null => { + 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: [number, number] = [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: any, link: any): { + source: [number, number]; + target: [number, number]; + midpoint: [number, number]; + sourceNode: any; +} | null => { + 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: [number, number] = [ + (source[0] + target[0]) / 2, + (source[1] + target[1]) / 2, + ]; + return { source, target, midpoint, sourceNode }; +}; + +const getLayerForgeVirtualLinkColor = (link: any): string => { + const colors = (globalThis as any).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: any, context: CanvasRenderingContext2D): void => { + const graph = canvas?.graph || app.graph; + if (!graph?._nodes || canvas.links_render_mode === (globalThis as any).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: any, event: any): [number, number] => { + 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: any, x: number, y: number): { + targetNode: any; + index: number; + point: [number, number]; + distance: number; +} | null => { + let best: { + targetNode: any; + index: number; + point: [number, number]; + distance: number; + } | null = 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: any, point: [number, number]): { x: number; y: number } | null => { + 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: any, hit: { + targetNode: any; + index: number; + point: [number, number]; +}, event: any): void => { + const ContextMenu = (globalThis as any).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 as any).PointerEvent; + const MouseEventConstructor = (globalThis as any).MouseEvent; + let menuEvent: any; + + try { + const EventConstructor = PointerEventConstructor || MouseEventConstructor; + menuEvent = EventConstructor + ? new EventConstructor('pointerdown', { + clientX, + clientY, + bubbles: true, + cancelable: true, + }) + : { clientX, clientY }; + } catch { + menuEvent = { clientX, clientY }; + } + + let menuInstance: any = null; + const closeMenu = (): void => { + 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: any): void => { + 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: any, targetNode: any, position: [number, number]): boolean => { + const graph = canvas?.graph || app.graph; + const LiteGraph = (globalThis as any).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: any) => { + 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: any): any => { + const clientX = Number(detail?.clientX) || 0; + const clientY = Number(detail?.clientY) || 0; + const PointerEventConstructor = (globalThis as any).PointerEvent; + const MouseEventConstructor = (globalThis as any).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 = (): void => { + const documentObject = (globalThis as any).document; + if (!documentObject) return; + + const close = (): void => { + 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 as any).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: any, targetNode: any, detail: any): void => { + const ContextMenu = (globalThis as any).LiteGraph?.ContextMenu; + if (typeof ContextMenu !== 'function' || !targetNode) return; + + layerForgeQuickCreateMenu?.close?.(); + layerForgeQuickCreateMenu?.remove?.(); + layerForgeQuickCreateMenu = null; + + const position: [number, number] = [ + Number(detail?.canvasX) || 0, + Number(detail?.canvasY) || 0, + ]; + const finish = (): void => { + 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: any): { inputLinkId: any; virtualLinkCount: number } => { + const targetNode = pending?.targetNode; + const input = getLayerForgeImageInputSlot(targetNode); + return { + inputLinkId: input?.link ?? null, + virtualLinkCount: getLayerForgeImageInputLinks(targetNode).length, + }; +}; + +const scheduleLayerForgeQuickCreateMenu = (canvas: any, event: any, pending: any): boolean => { + 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 = (): boolean => { + 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 = (): void => { + 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: any): (() => void) | null => { + const events = canvas?.linkConnector?.events; + if (!events) return null; + + const preventReset = (event: any): void => event?.preventDefault?.(); + events.addEventListener?.('reset', preventReset, { once: true }); + return () => events.removeEventListener?.('reset', preventReset, { once: true }); +}; + +const hasLayerForgePendingConnection = (canvas: any): boolean => Boolean( + canvas?.connecting_node + || canvas?.connectingNode + || canvas?.connecting_input + || canvas?.connectingInput + || canvas?.linkConnector?.renderLinks?.length, +); + +const shouldSuppressLayerForgeNativeDrop = (type: string): boolean => ( + type === 'dropped-on-canvas' + && Boolean(layerForgeQuickCreateMenu || layerForgeQuickCreatePending) + && performance.now() < layerForgeSuppressNativeDropUntil +); + +const primeLayerForgeInputDropSuppression = (canvas: any): boolean => { + const pending = getLayerForgePendingConnectorLink(canvas); + if (pending?.direction !== 'from_input') return false; + + layerForgeQuickCreatePending = true; + layerForgeSuppressNativeDropUntil = performance.now() + 1000; + return true; +}; + +const installLayerForgeQuickCreateCapture = (canvas: any): boolean => { + 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: any): void => { + 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: any) => { + 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 as any).window, + (globalThis as any).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: any = null; + let wrappedDispatchEvent: any = null; + if (originalDispatch) { + wrappedDispatch = function dispatchWithLayerForgeDropGuard(type: string, detail: any) { + 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: any) { + 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 = (): void => { + primeLayerForgeInputDropSuppression(canvas); + }; + const droppedOnCanvasHandler = (event: any): void => { + 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 = (): void => { + const canvas = (app as any).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(this: any, context: CanvasRenderingContext2D) { + 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(this: any, event: any) { + 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: any): void => { + 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: any, inputIndex: number, linkInfo: any = null): boolean => { + 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: any, inputIndex: number, linkInfo: any = null): void => { + setTimeout(() => convertLayerForgeImageConnection(node, inputIndex, linkInfo), 0); + if (!linkInfo) setTimeout(() => convertLayerForgeImageConnection(node, inputIndex), 50); +}; + +const pruneLayerForgeTransportInputs = (node: any): void => { + 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 = (): void => { + const appWithPrompt = app as any; + const originalGraphToPrompt = appWithPrompt.graphToPrompt; + if (typeof originalGraphToPrompt !== 'function' || originalGraphToPrompt.__layerForgeMultiImagePatched) return; + + const graphToPrompt = async function (this: any, ...args: any[]): Promise { + 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 ||= {}; + + 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 (this: ComfyApp, number: number, prompt: any) { + installLayerForgeMultiImagePromptPatch(); + installLayerForgeVirtualWirePatch(); log.info("Preparing to queue prompt..."); if (canvasNodeInstances.size > 0) { @@ -2030,6 +2920,9 @@ app.registerExtension({ nodeWithPreviewHook.__layerForgePreviewWidgetHooked = true; } + (this as any).properties ||= {}; + pruneLayerForgeTransportInputs(this); + this.size = [1150, 1000]; return r; }; @@ -2110,8 +3003,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; @@ -2129,9 +3023,19 @@ app.registerExtension({ // Add onConnectionsChange handler to detect when inputs are connected nodeType.prototype.onConnectionsChange = function (this: ComfyNode, type: number, index: number, connected: boolean, link_info: any) { log.info(`onConnectionsChange called: type=${type}, index=${index}, connected=${connected}`, link_info); + + if ((this as any).__layerForgeVirtualWireClearing) return; // Check if this is an input connection (type 1 = INPUT) if (type === 1) { + const inputName = String((this.inputs as any)?.[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 as any).canvasWidget; const canvas = canvasWidget?.canvas || canvasWidget; @@ -2149,7 +3053,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); @@ -2166,8 +3070,8 @@ app.registerExtension({ 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 @@ -2179,7 +3083,7 @@ app.registerExtension({ 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 as any).maskAppliedFromInput && canvas.maskTool) { canvas.maskTool.clear(); canvas.render(); @@ -2206,7 +3110,7 @@ app.registerExtension({ } // Handle input_mask connection (index 1) - if (index === 1) { + if (isMaskInput) { if (connected && link_info) { log.info("Input mask connected"); @@ -2324,6 +3228,9 @@ app.registerExtension({ originalConfigure.apply(this, [data]); } + (this as any).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) { @@ -2453,7 +3360,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 as any).canvasWidget?.canvas; + if (canvas) { + canvas.inputDataLoaded = false; + canvas.lastLoadedImageSrc = undefined; + canvas.lastLoadedLinkId = undefined; + } + }, + }] : []), { content: "Open in MaskEditor", callback: async () => { diff --git a/src/types.ts b/src/types.ts index 8715a08..7727676 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/src/utils/MultiImageInputUtils.ts b/src/utils/MultiImageInputUtils.ts new file mode 100644 index 0000000..b427772 --- /dev/null +++ b/src/utils/MultiImageInputUtils.ts @@ -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(); + + 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; +} diff --git a/tests/test_entrypoint_integration.py b/tests/test_entrypoint_integration.py index 1e041a8..783d516 100644 --- a/tests/test_entrypoint_integration.py +++ b/tests/test_entrypoint_integration.py @@ -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 diff --git a/tests/test_multi_image_input_characterization.mjs b/tests/test_multi_image_input_characterization.mjs new file mode 100644 index 0000000..73b88cf --- /dev/null +++ b/tests/test_multi_image_input_characterization.mjs @@ -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" \}\)/); +});