mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
Fixed batch image processing to prevent duplicates and layer deletion while ensuring proper mask loading from input_mask. Images are now added as new layers without removing existing ones, and masks are always checked from backend regardless of image state.
1030 lines
55 KiB
JavaScript
1030 lines
55 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";
|
|
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() {
|
|
try {
|
|
const nodeId = this.canvas.node.id;
|
|
log.info(`Checking for input data for node ${nodeId}...`);
|
|
// 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
|
|
if (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 (graph) {
|
|
const link = graph.links[linkId];
|
|
if (link) {
|
|
const sourceNode = graph.getNodeById(link.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
|
|
if (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.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 ${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
|
|
}
|
|
}
|
|
}
|
|
// Even if images are already loaded from connected nodes, still check backend for fresh execution data.
|
|
// We'll dedupe by comparing backend payload hash with lastLoadedImageSrc.
|
|
const nodeInputs = this.canvas.node.inputs;
|
|
// Check backend for input data
|
|
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
|
|
let shouldCheckMask = false;
|
|
if (this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
|
shouldCheckMask = true;
|
|
}
|
|
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 (!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
|
|
}
|
|
}
|
|
// Always check for mask data from backend when there's a mask input
|
|
// Don't rely on maskLoaded flag from nodeOutputs check
|
|
if (this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
|
const currentMaskLinkId = this.canvas.node.inputs[1].link;
|
|
// Always reset mask loaded flag and clear lastLoadedMaskLinkId to force fresh load
|
|
maskLoaded = false;
|
|
// Clear the stored mask link to force reload from backend
|
|
this.canvas.lastLoadedMaskLinkId = undefined;
|
|
log.debug(`Mask input detected, will force check backend for mask data`);
|
|
}
|
|
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) if provided and not already loaded
|
|
if (!imageLoaded) {
|
|
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");
|
|
}
|
|
}
|
|
// Handle mask separately (not tied to layer) if not already loaded
|
|
if (!maskLoaded && 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 fitOnAddWidget = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
|
|
const shouldFit = fitOnAddWidget && fitOnAddWidget.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);
|
|
}
|
|
// Save the mask state
|
|
this.canvas.canvasState.saveMaskState();
|
|
log.info("Applied input mask to mask tool" + (shouldFit ? " (fitted to output area)" : " (original size)"));
|
|
}
|
|
}
|
|
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();
|
|
});
|
|
}
|
|
}
|