import { createCanvas } from "./utils/CommonUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js"; import { showErrorNotification } from "./utils/NotificationUtils.js"; import { webSocketManager } from "./utils/WebSocketManager.js"; const log = createModuleLogger('CanvasIO'); export class CanvasIO { constructor(canvas) { this.canvas = canvas; this._saveInProgress = null; } async saveToServer(fileName, outputMode = 'disk') { if (outputMode === 'disk') { if (!window.canvasSaveStates) { window.canvasSaveStates = new Map(); } const nodeId = this.canvas.node.id; const saveKey = `${nodeId}_${fileName}`; if (this._saveInProgress || window.canvasSaveStates.get(saveKey)) { log.warn(`Save already in progress for node ${nodeId}, waiting...`); return this._saveInProgress || window.canvasSaveStates.get(saveKey); } log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`); this._saveInProgress = this._performSave(fileName, outputMode); window.canvasSaveStates.set(saveKey, this._saveInProgress); try { return await this._saveInProgress; } finally { this._saveInProgress = null; window.canvasSaveStates.delete(saveKey); log.debug(`Save completed for node ${nodeId}, lock released`); } } else { log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`); return this._performSave(fileName, outputMode); } } async _performSave(fileName, outputMode) { if (this.canvas.layers.length === 0) { log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`); return Promise.resolve(true); } await this.canvas.canvasState.saveStateToDB(); const nodeId = this.canvas.node.id; const delay = (nodeId % 10) * 50; if (delay > 0) { await new Promise(resolve => setTimeout(resolve, delay)); } return new Promise((resolve) => { const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height); const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height); const originalShape = this.canvas.outputAreaShape; this.canvas.outputAreaShape = null; const { canvas: visibilityCanvas, ctx: visibilityCtx } = createCanvas(this.canvas.width, this.canvas.height, '2d', { alpha: true }); if (!visibilityCtx) throw new Error("Could not create visibility context"); if (!maskCtx) throw new Error("Could not create mask context"); if (!tempCtx) throw new Error("Could not create temp context"); maskCtx.fillStyle = '#ffffff'; maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height); log.debug(`Canvas contexts created, starting layer rendering`); this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers); this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers); log.debug(`Finished rendering layers`); const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); for (let i = 0; i < visibilityData.data.length; i += 4) { const alpha = visibilityData.data[i + 3]; const maskValue = 255 - alpha; maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue; maskData.data[i + 3] = 255; } maskCtx.putImageData(maskData, 0, 0); this.canvas.outputAreaShape = originalShape; // Use optimized getMaskForOutputArea() instead of getMask() for better performance // This only processes chunks that overlap with the output area const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea(); if (toolMaskCanvas) { log.debug(`Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height}) instead of full mask`); // The optimized mask is already sized and positioned for the output area // So we can draw it directly without complex positioning calculations const tempMaskData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height); if (tempMaskData) { // Ensure the mask data is in the correct format (white with alpha) for (let i = 0; i < tempMaskData.data.length; i += 4) { const alpha = tempMaskData.data[i + 3]; tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; tempMaskData.data[i + 3] = alpha; } // Create a temporary canvas to hold the processed mask const { canvas: tempMaskCanvas, ctx: tempMaskCtx } = createCanvas(this.canvas.width, this.canvas.height, '2d', { willReadFrequently: true }); if (!tempMaskCtx) throw new Error("Could not create temp mask context"); // Put the processed mask data into a canvas that matches the output area size const { canvas: outputMaskCanvas, ctx: outputMaskCtx } = createCanvas(toolMaskCanvas.width, toolMaskCanvas.height, '2d', { willReadFrequently: true }); if (!outputMaskCtx) throw new Error("Could not create output mask context"); outputMaskCtx.putImageData(tempMaskData, 0, 0); // Draw the optimized mask at the correct position (output area bounds) const bounds = this.canvas.outputAreaBounds; tempMaskCtx.drawImage(outputMaskCanvas, bounds.x, bounds.y); maskCtx.globalCompositeOperation = 'source-over'; maskCtx.drawImage(tempMaskCanvas, 0, 0); } } if (outputMode === 'ram') { const imageData = tempCanvas.toDataURL('image/png'); const maskData = maskCanvas.toDataURL('image/png'); log.info("Returning image and mask data as base64 for RAM mode."); resolve({ image: imageData, mask: maskData }); return; } const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png'); log.info(`Saving image without mask as: ${fileNameWithoutMask}`); tempCanvas.toBlob(async (blobWithoutMask) => { if (!blobWithoutMask) return; log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`); const formDataWithoutMask = new FormData(); formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask); formDataWithoutMask.append("overwrite", "true"); try { const response = await fetch("/upload/image", { method: "POST", body: formDataWithoutMask, }); log.debug(`Image without mask upload response: ${response.status}`); } catch (error) { log.error(`Error uploading image without mask:`, error); } }, "image/png"); log.info(`Saving main image as: ${fileName}`); tempCanvas.toBlob(async (blob) => { if (!blob) return; log.debug(`Created blob for main image, size: ${blob.size} bytes`); const formData = new FormData(); formData.append("image", blob, fileName); formData.append("overwrite", "true"); try { const resp = await fetch("/upload/image", { method: "POST", body: formData, }); log.debug(`Main image upload response: ${resp.status}`); if (resp.status === 200) { const maskFileName = fileName.replace('.png', '_mask.png'); log.info(`Saving mask as: ${maskFileName}`); maskCanvas.toBlob(async (maskBlob) => { if (!maskBlob) return; log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`); const maskFormData = new FormData(); maskFormData.append("image", maskBlob, maskFileName); maskFormData.append("overwrite", "true"); try { const maskResp = await fetch("/upload/image", { method: "POST", body: maskFormData, }); log.debug(`Mask upload response: ${maskResp.status}`); if (maskResp.status === 200) { const data = await resp.json(); if (this.canvas.widget) { this.canvas.widget.value = fileName; } log.info(`All files saved successfully, widget value set to: ${fileName}`); resolve(true); } else { log.error(`Error saving mask: ${maskResp.status}`); resolve(false); } } catch (error) { log.error(`Error saving mask:`, error); resolve(false); } }, "image/png"); } else { log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`); resolve(false); } } catch (error) { log.error(`Error uploading main image:`, error); resolve(false); } }, "image/png"); }); } async _renderOutputData() { log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ==="); // Użyj zunifikowanych funkcji z CanvasLayers const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob(); if (!imageBlob || !maskBlob) { throw new Error("Failed to generate canvas or mask blobs"); } // Konwertuj blob na data URL const imageDataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(imageBlob); }); const maskDataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(maskBlob); }); const bounds = this.canvas.outputAreaBounds; log.info(`=== OUTPUT DATA GENERATED ===`); log.info(`Image size: ${bounds.width}x${bounds.height}`); log.info(`Image data URL length: ${imageDataUrl.length}`); log.info(`Mask data URL length: ${maskDataUrl.length}`); return { image: imageDataUrl, mask: maskDataUrl }; } async sendDataViaWebSocket(nodeId) { log.info(`Preparing to send data for node ${nodeId} via WebSocket.`); const { image, mask } = await this._renderOutputData(); try { log.info(`Sending data for node ${nodeId}...`); await webSocketManager.sendMessage({ type: 'canvas_data', nodeId: String(nodeId), image: image, mask: mask, }, true); // `true` requires an acknowledgment log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`); return true; } catch (error) { log.error(`Failed to send data for node ${nodeId}:`, error); throw new Error(`Failed to get confirmation from server for node ${nodeId}. ` + `Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` + `If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` + `which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`); } } async addInputToCanvas(inputImage, inputMask) { try { log.debug("Adding input to canvas:", { inputImage }); const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height); if (!tempCtx) throw new Error("Could not create temp context"); const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), inputImage.width, inputImage.height); tempCtx.putImageData(imgData, 0, 0); const image = new Image(); await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = reject; image.src = tempCanvas.toDataURL(); }); const bounds = this.canvas.outputAreaBounds; const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.height / inputImage.height * 0.8); const layer = await this.canvas.canvasLayers.addLayerWithImage(image, { x: bounds.x + (bounds.width - inputImage.width * scale) / 2, y: bounds.y + (bounds.height - inputImage.height * scale) / 2, width: inputImage.width * scale, height: inputImage.height * scale, }); if (inputMask && layer) { layer.mask = inputMask.data; } log.info("Layer added successfully"); return true; } catch (error) { log.error("Error in addInputToCanvas:", error); throw error; } } async convertTensorToImage(tensor) { try { log.debug("Converting tensor to image:", tensor); if (!tensor || !tensor.data || !tensor.width || !tensor.height) { throw new Error("Invalid tensor data"); } const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true }); if (!ctx) throw new Error("Could not create canvas context"); const imageData = new ImageData(new Uint8ClampedArray(tensor.data), tensor.width, tensor.height); ctx.putImageData(imageData, 0, 0); return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = (e) => reject(new Error("Failed to load image: " + e)); img.src = canvas.toDataURL(); }); } catch (error) { log.error("Error converting tensor to image:", error); throw error; } } async convertTensorToMask(tensor) { if (!tensor || !tensor.data) { throw new Error("Invalid mask tensor"); } try { return new Float32Array(tensor.data); } catch (error) { throw new Error(`Mask conversion failed: ${error.message}`); } } async initNodeData() { try { log.info("Starting node data initialization..."); // First check for input data from the backend (new feature) await this.checkForInputData(); // If we've already loaded input data, don't continue with old initialization if (this.canvas.inputDataLoaded) { log.debug("Input data already loaded, skipping old initialization"); this.canvas.dataInitialized = true; return; } if (!this.canvas.node || !this.canvas.node.inputs) { 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; // Check if we already loaded this link if (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]; if (imageData) { log.debug("Found image data:", imageData); await this.processImageData(imageData); this.canvas.dataInitialized = true; } else { log.debug("Image data not available yet"); return this.scheduleDataCheck(); } } else { // 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 maskData = window.app.nodeOutputs[maskLinkId]; if (maskData) { log.debug("Found mask data:", maskData); await this.processMaskData(maskData); } } } catch (error) { log.error("Error in initNodeData:", error); return this.scheduleDataCheck(); } } async checkForInputData(options) { try { const nodeId = this.canvas.node.id; const allowImage = options?.allowImage ?? true; const allowMask = options?.allowMask ?? true; const reason = options?.reason ?? 'unspecified'; log.info(`Checking for input data for node ${nodeId}... opts: image=${allowImage}, mask=${allowMask}, reason=${reason}`); // Track loaded links separately for image and mask 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 = sourceNode.imgs.map((img) => img.src).join('|'); // 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; } } } } 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 = sourceNode.imgs.map((img) => img.src).join('|'); // 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 fitOnAddWidget = this.canvas.node.widgets.find((w) => w.name === "fit_on_add"); const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center'; // Add all images from the batch as separate layers for (let i = 0; i < sourceNode.imgs.length; i++) { const img = sourceNode.imgs[i]; await this.canvas.canvasLayers.addLayerWithImage(img, { name: `Batch Image ${i + 1}` }, // Give each layer a unique name addMode, this.canvas.outputAreaBounds); log.debug(`Added batch image ${i + 1}/${sourceNode.imgs.length} 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(); } } } } } // 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; // Check if we already loaded this mask link if (this.canvas.lastLoadedMaskLinkId === maskLinkId) { log.debug(`Mask link ${maskLinkId} already loaded`); maskLoaded = true; } else { // Try to get mask tensor from nodeOutputs using origin_id (not link id) const graph = this.canvas.node.graph; let maskOutput = null; if (graph) { const link = graph.links[maskLinkId]; if (link && link.origin_id) { // Use origin_id to get the actual node output const nodeOutput = window.app?.nodeOutputs?.[link.origin_id]; log.debug(`Looking for mask output from origin node ${link.origin_id}, found:`, !!nodeOutput); if (nodeOutput) { log.debug(`Node ${link.origin_id} output structure:`, { hasData: !!nodeOutput.data, hasShape: !!nodeOutput.shape, dataType: typeof nodeOutput.data, shapeType: typeof nodeOutput.shape, keys: Object.keys(nodeOutput) }); // Only use if it has actual tensor data if (nodeOutput.data && nodeOutput.shape) { maskOutput = nodeOutput; } } } } if (maskOutput && maskOutput.data && maskOutput.shape) { try { // Derive dimensions from shape or explicit width/height let width = maskOutput.width || 0; let height = maskOutput.height || 0; const shape = maskOutput.shape; // e.g. [1,H,W] or [1,H,W,1] if ((!width || !height) && Array.isArray(shape)) { if (shape.length >= 3) { height = shape[1]; width = shape[2]; } else if (shape.length === 2) { height = shape[0]; width = shape[1]; } } if (!width || !height) { throw new Error("Cannot determine mask dimensions from nodeOutputs"); } // Determine channels count let channels = 1; if (Array.isArray(shape) && shape.length >= 4) { channels = shape[3]; } else if (maskOutput.channels) { channels = maskOutput.channels; } else { const len = maskOutput.data.length; channels = Math.max(1, Math.floor(len / (width * height))); } // Create GRAYSCALE image from tensor; RGB = luminance, A = 255 (MaskTool.setMask reads luminance) const { canvas: maskCanvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true }); if (!ctx) throw new Error("Could not create mask context"); const imgData = ctx.createImageData(width, height); const arr = maskOutput.data; const min = (maskOutput.min_val !== undefined) ? maskOutput.min_val : 0; const max = (maskOutput.max_val !== undefined) ? maskOutput.max_val : 1; const denom = (max - min) || 1; const pixelCount = width * height; for (let i = 0; i < pixelCount; i++) { const baseIndex = i * channels; let v; if (channels === 1) { v = arr[i]; } else if (channels >= 3) { // If image-like, compute luminance from RGB channels const r = arr[baseIndex + 0] ?? 0; const g = arr[baseIndex + 1] ?? 0; const b = arr[baseIndex + 2] ?? 0; v = 0.299 * r + 0.587 * g + 0.114 * b; } else { v = arr[baseIndex] ?? 0; } let norm = (v - min) / denom; if (!isFinite(norm)) norm = 0; norm = Math.max(0, Math.min(1, norm)); const lum = Math.round(norm * 255); const o = i * 4; imgData.data[o] = lum; // R imgData.data[o + 1] = lum; // G imgData.data[o + 2] = lum; // B imgData.data[o + 3] = 255; // A fixed (MaskTool computes alpha from luminance) } ctx.putImageData(imgData, 0, 0); // Convert to HTMLImageElement const maskImg = await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = maskCanvas.toDataURL(); }); // Respect fit_on_add (scale to output area) const widgets = this.canvas.node.widgets; const fitOnAddWidget = widgets ? widgets.find((w) => w.name === "fit_on_add") : null; const shouldFit = fitOnAddWidget && fitOnAddWidget.value; let finalMaskImg = maskImg; if (shouldFit) { const bounds = this.canvas.outputAreaBounds; const scale = Math.min(bounds.width / maskImg.width, bounds.height / maskImg.height); const scaledWidth = Math.max(1, Math.round(maskImg.width * scale)); const scaledHeight = Math.max(1, Math.round(maskImg.height * scale)); const { canvas: scaledCanvas, ctx: scaledCtx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true }); if (!scaledCtx) throw new Error("Could not create scaled mask context"); scaledCtx.drawImage(maskImg, 0, 0, scaledWidth, scaledHeight); finalMaskImg = await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = scaledCanvas.toDataURL(); }); } // Apply to MaskTool (centers internally) if (this.canvas.maskTool) { this.canvas.maskTool.setMask(finalMaskImg, true); this.canvas.maskAppliedFromInput = true; this.canvas.canvasState.saveMaskState(); this.canvas.render(); // Mark this mask link as loaded to avoid re-applying this.canvas.lastLoadedMaskLinkId = maskLinkId; maskLoaded = true; log.info("Applied input mask from nodeOutputs immediately on connection" + (shouldFit ? " (fitted to output area)" : "")); } } catch (err) { log.warn("Failed to apply mask from nodeOutputs immediately; will wait for backend input_mask after execution", err); } } else { // nodeOutputs exist but don't have tensor data yet (need workflow execution) log.info(`Mask node ${this.canvas.node.graph?.links[maskLinkId]?.origin_id} found but has no tensor data yet. Mask will be applied automatically after workflow execution.`); // Don't retry - data won't be available until workflow runs } } } // 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; // If mask input is disconnected, clear any currently applied mask to ensure full separation if (!hasMaskInput) { this.canvas.maskAppliedFromInput = false; this.canvas.lastLoadedMaskLinkId = undefined; log.info("Mask input disconnected - cleared mask to enforce separation from input_image"); } if (!hasImageInput && !hasMaskInput) { log.debug("No inputs connected, skipping backend check"); this.canvas.inputDataLoaded = true; return; } // Skip backend check during mask connection if we didn't get immediate data if (reason === "mask_connect" && !maskLoaded) { log.info("No immediate mask data available during connection, skipping backend check to avoid stale data. Will check after execution."); return; } // Check backend for input data only if we have connected inputs const response = await fetch(`/layerforge/get_input_data/${nodeId}`); const result = await response.json(); if (result.success && result.has_input) { // Dedupe: skip only if backend payload matches last loaded batch hash let backendBatchHash; if (result.data?.input_images_batch && Array.isArray(result.data.input_images_batch)) { backendBatchHash = result.data.input_images_batch.map((i) => i.data).join('|'); } else if (result.data?.input_image) { backendBatchHash = result.data.input_image; } // Check mask separately - don't skip if only images are unchanged AND mask is actually connected AND allowed const shouldCheckMask = hasMaskInput && allowMask; if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && !shouldCheckMask) { log.debug("Backend input data unchanged and no mask to check, skipping reload"); this.canvas.inputDataLoaded = true; return; } else if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && shouldCheckMask) { log.debug("Images unchanged but need to check mask, continuing..."); 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 (this.canvas.lastLoadedLinkId !== currentLinkId) { // Mark this link as loaded this.canvas.lastLoadedLinkId = currentLinkId; imageLoaded = false; // Will load from backend } } // 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; // Only reset if this is a different mask link than what we loaded before if (this.canvas.lastLoadedMaskLinkId !== currentMaskLinkId) { maskLoaded = false; log.debug(`New mask input detected (${currentMaskLinkId}), will check backend for mask data`); } else { log.debug(`Same mask input (${currentMaskLinkId}), mask already loaded`); maskLoaded = true; } } else { // No mask input connected, or mask loading not allowed right now maskLoaded = true; // Mark as loaded to skip mask processing if (!allowMask) { log.debug("Mask loading is currently disabled by caller, skipping mask check"); } else { log.debug("No mask input connected, skipping mask check"); } } log.info("Input data found from backend, adding to canvas"); const inputData = result.data; // Compute backend batch hash for dedupe and state let backendHashNow; if (inputData?.input_images_batch && Array.isArray(inputData.input_images_batch)) { backendHashNow = inputData.input_images_batch.map((i) => i.data).join('|'); } else if (inputData?.input_image) { backendHashNow = inputData.input_image; } // Just update the hash without removing any layers if (backendHashNow) { log.info("New backend input data detected, adding new layers"); this.canvas.lastLoadedImageSrc = backendHashNow; } // Mark that we've loaded input data for this execution this.canvas.inputDataLoaded = true; // Determine add mode based on fit_on_add setting const widgets = this.canvas.node.widgets; const fitOnAddWidget = widgets ? widgets.find((w) => w.name === "fit_on_add") : null; const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center'; // 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`); for (let i = 0; i < batch.length; i++) { const imgData = batch[i]; const img = new Image(); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = imgData.data; }); // Add image to canvas with unique name await this.canvas.canvasLayers.addLayerWithImage(img, { name: `Batch Image ${i + 1}` }, addMode, this.canvas.outputAreaBounds); log.debug(`Added batch image ${i + 1}/${batch.length} from backend`); } log.info(`All ${batch.length} batch images added from backend`); this.canvas.render(); this.canvas.saveState(); } else if (inputData.input_image) { // Handle single image (backward compatibility) const img = new Image(); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = inputData.input_image; }); // Add image to canvas at output area position await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode, this.canvas.outputAreaBounds); log.info("Single input image added as new layer to canvas"); this.canvas.render(); this.canvas.saveState(); } else { log.debug("No input image data from backend"); } } else if (!hasImageInput && (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 if (allowMask && !maskLoaded && hasMaskInput && inputData.input_mask) { log.info("Processing input mask"); // Load mask image const maskImg = new Image(); await new Promise((resolve, reject) => { maskImg.onload = resolve; maskImg.onerror = reject; maskImg.src = inputData.input_mask; }); // Determine if we should fit the mask or use it at original size const fitOnAddWidget2 = this.canvas.node.widgets.find((w) => w.name === "fit_on_add"); const shouldFit = fitOnAddWidget2 && fitOnAddWidget2.value; if (shouldFit && this.canvas.maskTool) { // Scale mask to fit output area if fit_on_add is enabled const bounds = this.canvas.outputAreaBounds; const scale = Math.min(bounds.width / maskImg.width, bounds.height / maskImg.height); // Create scaled mask canvas const scaledWidth = Math.round(maskImg.width * scale); const scaledHeight = Math.round(maskImg.height * scale); const { canvas: scaledCanvas, ctx: scaledCtx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true }); if (!scaledCtx) throw new Error("Could not create scaled mask context"); // Draw scaled mask scaledCtx.drawImage(maskImg, 0, 0, scaledWidth, scaledHeight); // Convert scaled canvas to image const scaledMaskImg = new Image(); await new Promise((resolve, reject) => { scaledMaskImg.onload = resolve; scaledMaskImg.onerror = reject; scaledMaskImg.src = scaledCanvas.toDataURL(); }); // Apply scaled mask to mask tool this.canvas.maskTool.setMask(scaledMaskImg, true); } else if (this.canvas.maskTool) { // Apply mask at original size this.canvas.maskTool.setMask(maskImg, true); } this.canvas.maskAppliedFromInput = true; // Save the mask state this.canvas.canvasState.saveMaskState(); log.info("Applied input mask to mask tool" + (shouldFit ? " (fitted to output area)" : " (original size)")); } else if (!hasMaskInput && inputData.input_mask) { log.debug("Backend has mask data but no mask input connected, skipping mask load"); } else if (!allowMask && inputData.input_mask) { log.debug("Mask input data present in backend but mask loading is disabled by caller; skipping"); } } else { log.debug("No input data from backend"); // Don't schedule another check - we'll only check when explicitly triggered } } catch (error) { log.error("Error checking for input data:", error); // Don't schedule another check on error } } scheduleInputDataCheck() { // Schedule a retry for mask data check when nodeOutputs are not ready yet if (this.canvas.pendingInputDataCheck) { clearTimeout(this.canvas.pendingInputDataCheck); } this.canvas.pendingInputDataCheck = window.setTimeout(() => { this.canvas.pendingInputDataCheck = null; log.debug("Retrying input data check for mask..."); }, 500); // Shorter delay for mask data retry } scheduleDataCheck() { if (this.canvas.pendingDataCheck) { clearTimeout(this.canvas.pendingDataCheck); } this.canvas.pendingDataCheck = window.setTimeout(() => { this.canvas.pendingDataCheck = null; if (!this.canvas.dataInitialized) { this.initNodeData(); } }, 1000); } async processImageData(imageData) { try { if (!imageData) return; log.debug("Processing image data:", { type: typeof imageData, isArray: Array.isArray(imageData), shape: imageData.shape, hasData: !!imageData.data }); if (Array.isArray(imageData)) { imageData = imageData[0]; } if (!imageData.shape || !imageData.data) { throw new Error("Invalid image data format"); } const originalWidth = imageData.shape[2]; const originalHeight = imageData.shape[1]; const scale = Math.min(this.canvas.width / originalWidth * 0.8, this.canvas.height / originalHeight * 0.8); const convertedData = this.convertTensorToImageData(imageData); if (convertedData) { const image = await this.createImageFromData(convertedData); this.addScaledLayer(image, scale); log.info("Image layer added successfully with scale:", scale); } } catch (error) { log.error("Error processing image data:", error); throw error; } } addScaledLayer(image, scale) { try { const scaledWidth = image.width * scale; const scaledHeight = image.height * scale; const layer = { id: '', // This will be set in addLayerWithImage imageId: '', // This will be set in addLayerWithImage name: 'Layer', image: image, x: (this.canvas.width - scaledWidth) / 2, y: (this.canvas.height - scaledHeight) / 2, width: scaledWidth, height: scaledHeight, rotation: 0, zIndex: this.canvas.layers.length, originalWidth: image.width, originalHeight: image.height, blendMode: 'normal', opacity: 1, visible: true }; this.canvas.layers.push(layer); this.canvas.updateSelection([layer]); this.canvas.render(); log.debug("Scaled layer added:", { originalSize: `${image.width}x${image.height}`, scaledSize: `${scaledWidth}x${scaledHeight}`, scale: scale }); } catch (error) { log.error("Error adding scaled layer:", error); throw error; } } convertTensorToImageData(tensor) { try { const shape = tensor.shape; const height = shape[1]; const width = shape[2]; const channels = shape[3]; log.debug("Converting tensor:", { shape: shape, dataRange: { min: tensor.min_val, max: tensor.max_val } }); const imageData = new ImageData(width, height); const data = new Uint8ClampedArray(width * height * 4); const flatData = tensor.data; const pixelCount = width * height; for (let i = 0; i < pixelCount; i++) { const pixelIndex = i * 4; const tensorIndex = i * channels; for (let c = 0; c < channels; c++) { const value = flatData[tensorIndex + c]; const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val); data[pixelIndex + c] = Math.round(normalizedValue * 255); } data[pixelIndex + 3] = 255; } imageData.data.set(data); return imageData; } catch (error) { log.error("Error converting tensor:", error); return null; } } async createImageFromData(imageData) { return new Promise((resolve, reject) => { const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true }); if (!ctx) throw new Error("Could not create canvas context"); ctx.putImageData(imageData, 0, 0); const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = canvas.toDataURL(); }); } async processMaskData(maskData) { try { if (!maskData) return; log.debug("Processing mask data:", maskData); if (Array.isArray(maskData)) { maskData = maskData[0]; } if (!maskData.shape || !maskData.data) { throw new Error("Invalid mask data format"); } if (this.canvas.canvasSelection.selectedLayers.length > 0) { const maskTensor = await this.convertTensorToMask(maskData); this.canvas.canvasSelection.selectedLayers[0].mask = maskTensor; this.canvas.render(); log.info("Mask applied to selected layer"); } } catch (error) { log.error("Error processing mask data:", error); } } async importLatestImage() { try { log.info("Fetching latest image from server..."); const response = await fetch('/ycnode/get_latest_image'); const result = await response.json(); if (result.success && result.image_data) { log.info("Latest image received, adding to canvas."); const img = new Image(); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = result.image_data; }); await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit'); log.info("Latest image imported and placed on canvas successfully."); return true; } else { throw new Error(result.error || "Failed to fetch the latest image."); } } catch (error) { log.error("Error importing latest image:", error); showErrorNotification(`Failed to import latest image: ${error.message}`); return false; } } async importLatestImages(sinceTimestamp, targetArea = null) { try { log.info(`Fetching latest images since ${sinceTimestamp}...`); const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`); const result = await response.json(); if (result.success && result.images && result.images.length > 0) { log.info(`Received ${result.images.length} new images, adding to canvas.`); const newLayers = []; for (const imageData of result.images) { const img = new Image(); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = imageData; }); let processedImage = img; // If there's a custom shape, clip the image to that shape if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) { processedImage = await this.clipImageToShape(img, this.canvas.outputAreaShape); } const newLayer = await this.canvas.canvasLayers.addLayerWithImage(processedImage, {}, 'fit', targetArea); newLayers.push(newLayer); } log.info("All new images imported and placed on canvas successfully."); return newLayers.filter(l => l !== null); } else if (result.success) { log.info("No new images found since last generation."); return []; } else { throw new Error(result.error || "Failed to fetch latest images."); } } catch (error) { log.error("Error importing latest images:", error); showErrorNotification(`Failed to import latest images: ${error.message}`); return []; } } async clipImageToShape(image, shape) { return new Promise((resolve, reject) => { const { canvas, ctx } = createCanvas(image.width, image.height); if (!ctx) { reject(new Error("Could not create canvas context for clipping")); return; } // Draw the image first ctx.drawImage(image, 0, 0); // Calculate custom shape position accounting for extensions // Custom shape should maintain its relative position within the original canvas area const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 }; const shapeOffsetX = ext.left; // Add left extension to maintain relative position const shapeOffsetY = ext.top; // Add top extension to maintain relative position // Create a clipping mask using the shape with extension offset ctx.globalCompositeOperation = 'destination-in'; ctx.beginPath(); ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY); for (let i = 1; i < shape.points.length; i++) { ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY); } ctx.closePath(); ctx.fill(); // Create a new image from the clipped canvas const clippedImage = new Image(); clippedImage.onload = () => resolve(clippedImage); clippedImage.onerror = () => reject(new Error("Failed to create clipped image")); clippedImage.src = canvas.toDataURL(); }); } }