mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 12:52:10 -03:00
Increase robustness of image loading check before sending canvas data. Now retries up to 5 times with 200ms delays (1 second total) instead of a single 100ms wait. This fixes the 'Failed to get confirmation from server' error that appeared when executing workflows immediately after ComfyUI restart, before images finished loading from IndexedDB. Prevents workflow execution failures due to timing issues during canvas initialization.
950 lines
51 KiB
JavaScript
950 lines
51 KiB
JavaScript
import { createCanvas } from "./utils/CommonUtils.js";
|
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
|
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
|
import { webSocketManager } from "./utils/WebSocketManager.js";
|
|
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.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 ===");
|
|
// Check if layers have valid images loaded, with retry logic
|
|
const maxRetries = 5;
|
|
const retryDelay = 200;
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete);
|
|
if (layersWithoutImages.length === 0) {
|
|
break; // All images loaded
|
|
}
|
|
if (attempt === 0) {
|
|
log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`);
|
|
}
|
|
if (attempt < maxRetries - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
}
|
|
else {
|
|
// Last attempt failed
|
|
throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`);
|
|
}
|
|
}
|
|
// 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 });
|
|
// Use unified tensorToImageData for RGB image
|
|
const imageData = tensorToImageData(inputImage, 'rgb');
|
|
if (!imageData)
|
|
throw new Error("Failed to convert input image tensor");
|
|
// Create HTMLImageElement from ImageData
|
|
const image = await createImageFromImageData(imageData);
|
|
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 imageData = tensorToImageData(tensor, 'rgb');
|
|
if (!imageData)
|
|
throw new Error("Failed to convert tensor to image data");
|
|
return await createImageFromImageData(imageData);
|
|
}
|
|
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)));
|
|
}
|
|
// Use unified tensorToImageData for masks
|
|
const maskImageData = tensorToImageData(maskOutput, 'grayscale');
|
|
if (!maskImageData)
|
|
throw new Error("Failed to convert mask tensor to image data");
|
|
// Create canvas and put image data
|
|
const { canvas: maskCanvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
|
if (!ctx)
|
|
throw new Error("Could not create mask context");
|
|
ctx.putImageData(maskImageData, 0, 0);
|
|
// Convert to HTMLImageElement
|
|
const maskImg = await createImageFromSource(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;
|
|
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
|
|
}
|
|
// 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 = await createImageFromSource(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 = await createImageFromSource(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 = await createImageFromSource(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;
|
|
let finalMaskImg = maskImg;
|
|
if (shouldFit && this.canvas.maskTool) {
|
|
const bounds = this.canvas.outputAreaBounds;
|
|
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
|
|
}
|
|
// Apply to MaskTool (centers internally)
|
|
if (this.canvas.maskTool) {
|
|
this.canvas.maskTool.setMask(finalMaskImg, 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) {
|
|
return tensorToImageData(tensor, 'rgb');
|
|
}
|
|
async createImageFromData(imageData) {
|
|
return createImageFromImageData(imageData);
|
|
}
|
|
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 = await createImageFromSource(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) {
|
|
const { canvas, ctx } = createCanvas(image.width, image.height);
|
|
if (!ctx) {
|
|
throw new Error("Could not create canvas context for clipping");
|
|
}
|
|
// 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
|
|
return await createImageFromSource(canvas.toDataURL());
|
|
}
|
|
}
|