mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0e6bf8b3d | ||
|
|
da37900b33 | ||
|
|
64c5e49707 | ||
|
|
06d94f6a63 | ||
|
|
b21d6e3502 | ||
|
|
285ad035b2 | ||
|
|
949ffa0143 | ||
|
|
afdac52144 |
139
canvas_node.py
139
canvas_node.py
@@ -179,6 +179,10 @@ class LayerForgeNode:
|
|||||||
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
|
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
|
||||||
"node_id": ("STRING", {"default": "0"}),
|
"node_id": ("STRING", {"default": "0"}),
|
||||||
},
|
},
|
||||||
|
"optional": {
|
||||||
|
"input_image": ("IMAGE",),
|
||||||
|
"input_mask": ("MASK",),
|
||||||
|
},
|
||||||
"hidden": {
|
"hidden": {
|
||||||
"prompt": ("PROMPT",),
|
"prompt": ("PROMPT",),
|
||||||
"unique_id": ("UNIQUE_ID",),
|
"unique_id": ("UNIQUE_ID",),
|
||||||
@@ -239,7 +243,7 @@ class LayerForgeNode:
|
|||||||
|
|
||||||
_processing_lock = threading.Lock()
|
_processing_lock = threading.Lock()
|
||||||
|
|
||||||
def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, prompt=None, unique_id=None):
|
def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, input_image=None, input_mask=None, prompt=None, unique_id=None):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
@@ -250,6 +254,81 @@ class LayerForgeNode:
|
|||||||
|
|
||||||
log_info(f"Lock acquired. Starting process_canvas_image for node_id: {node_id} (fallback unique_id: {unique_id})")
|
log_info(f"Lock acquired. Starting process_canvas_image for node_id: {node_id} (fallback unique_id: {unique_id})")
|
||||||
|
|
||||||
|
# Always store fresh input data, even if None, to clear stale data
|
||||||
|
log_info(f"Storing input data for node {node_id} - Image: {input_image is not None}, Mask: {input_mask is not None}")
|
||||||
|
|
||||||
|
with self.__class__._storage_lock:
|
||||||
|
input_data = {}
|
||||||
|
|
||||||
|
if input_image is not None:
|
||||||
|
# Convert image tensor(s) to base64 - handle batch
|
||||||
|
if isinstance(input_image, torch.Tensor):
|
||||||
|
# Ensure correct shape [B, H, W, C]
|
||||||
|
if input_image.dim() == 3:
|
||||||
|
input_image = input_image.unsqueeze(0)
|
||||||
|
|
||||||
|
batch_size = input_image.shape[0]
|
||||||
|
log_info(f"Processing batch of {batch_size} image(s)")
|
||||||
|
|
||||||
|
if batch_size == 1:
|
||||||
|
# Single image - keep backward compatibility
|
||||||
|
img_np = (input_image.squeeze(0).cpu().numpy() * 255).astype(np.uint8)
|
||||||
|
pil_img = Image.fromarray(img_np, 'RGB')
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buffered = io.BytesIO()
|
||||||
|
pil_img.save(buffered, format="PNG")
|
||||||
|
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||||
|
input_data['input_image'] = f"data:image/png;base64,{img_str}"
|
||||||
|
input_data['input_image_width'] = pil_img.width
|
||||||
|
input_data['input_image_height'] = pil_img.height
|
||||||
|
log_debug(f"Stored single input image: {pil_img.width}x{pil_img.height}")
|
||||||
|
else:
|
||||||
|
# Multiple images - store as array
|
||||||
|
images_array = []
|
||||||
|
for i in range(batch_size):
|
||||||
|
img_np = (input_image[i].cpu().numpy() * 255).astype(np.uint8)
|
||||||
|
pil_img = Image.fromarray(img_np, 'RGB')
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buffered = io.BytesIO()
|
||||||
|
pil_img.save(buffered, format="PNG")
|
||||||
|
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||||
|
images_array.append({
|
||||||
|
'data': f"data:image/png;base64,{img_str}",
|
||||||
|
'width': pil_img.width,
|
||||||
|
'height': pil_img.height
|
||||||
|
})
|
||||||
|
log_debug(f"Stored batch image {i+1}/{batch_size}: {pil_img.width}x{pil_img.height}")
|
||||||
|
|
||||||
|
input_data['input_images_batch'] = images_array
|
||||||
|
log_info(f"Stored batch of {batch_size} images")
|
||||||
|
|
||||||
|
if input_mask is not None:
|
||||||
|
# Convert mask tensor to base64
|
||||||
|
if isinstance(input_mask, torch.Tensor):
|
||||||
|
# Ensure correct shape
|
||||||
|
if input_mask.dim() == 2:
|
||||||
|
input_mask = input_mask.unsqueeze(0)
|
||||||
|
if input_mask.dim() == 3 and input_mask.shape[0] == 1:
|
||||||
|
input_mask = input_mask.squeeze(0)
|
||||||
|
|
||||||
|
# Convert to numpy and then to PIL
|
||||||
|
mask_np = (input_mask.cpu().numpy() * 255).astype(np.uint8)
|
||||||
|
pil_mask = Image.fromarray(mask_np, 'L')
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
mask_buffered = io.BytesIO()
|
||||||
|
pil_mask.save(mask_buffered, format="PNG")
|
||||||
|
mask_str = base64.b64encode(mask_buffered.getvalue()).decode()
|
||||||
|
input_data['input_mask'] = f"data:image/png;base64,{mask_str}"
|
||||||
|
log_debug(f"Stored input mask: {pil_mask.width}x{pil_mask.height}")
|
||||||
|
|
||||||
|
input_data['fit_on_add'] = fit_on_add
|
||||||
|
|
||||||
|
# Store in a special key for input data (overwrites any previous data)
|
||||||
|
self.__class__._canvas_data_storage[f"{node_id}_input"] = input_data
|
||||||
|
|
||||||
storage_key = node_id
|
storage_key = node_id
|
||||||
|
|
||||||
processed_image = None
|
processed_image = None
|
||||||
@@ -433,6 +512,63 @@ class LayerForgeNode:
|
|||||||
log_info("WebSocket connection closed")
|
log_info("WebSocket connection closed")
|
||||||
return ws
|
return ws
|
||||||
|
|
||||||
|
@PromptServer.instance.routes.get("/layerforge/get_input_data/{node_id}")
|
||||||
|
async def get_input_data(request):
|
||||||
|
try:
|
||||||
|
node_id = request.match_info["node_id"]
|
||||||
|
log_debug(f"Checking for input data for node: {node_id}")
|
||||||
|
|
||||||
|
with cls._storage_lock:
|
||||||
|
input_key = f"{node_id}_input"
|
||||||
|
input_data = cls._canvas_data_storage.get(input_key, None)
|
||||||
|
|
||||||
|
if input_data:
|
||||||
|
log_info(f"Input data found for node {node_id}, sending to frontend")
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'has_input': True,
|
||||||
|
'data': input_data
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
log_debug(f"No input data found for node {node_id}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'has_input': False
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"Error in get_input_data: {str(e)}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
@PromptServer.instance.routes.post("/layerforge/clear_input_data/{node_id}")
|
||||||
|
async def clear_input_data(request):
|
||||||
|
try:
|
||||||
|
node_id = request.match_info["node_id"]
|
||||||
|
log_info(f"Clearing input data for node: {node_id}")
|
||||||
|
|
||||||
|
with cls._storage_lock:
|
||||||
|
input_key = f"{node_id}_input"
|
||||||
|
if input_key in cls._canvas_data_storage:
|
||||||
|
del cls._canvas_data_storage[input_key]
|
||||||
|
log_info(f"Input data cleared for node {node_id}")
|
||||||
|
else:
|
||||||
|
log_debug(f"No input data to clear for node {node_id}")
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Input data cleared for node {node_id}'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"Error in clear_input_data: {str(e)}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
@PromptServer.instance.routes.get("/ycnode/get_canvas_data/{node_id}")
|
@PromptServer.instance.routes.get("/ycnode/get_canvas_data/{node_id}")
|
||||||
async def get_canvas_data(request):
|
async def get_canvas_data(request):
|
||||||
try:
|
try:
|
||||||
@@ -911,4 +1047,3 @@ def convert_tensor_to_base64(tensor, alpha_mask=None, original_alpha=None):
|
|||||||
log_error(f"Error in convert_tensor_to_base64: {str(e)}")
|
log_error(f"Error in convert_tensor_to_base64: {str(e)}")
|
||||||
log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
|
log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export class Canvas {
|
|||||||
this.canvasContainer = null;
|
this.canvasContainer = null;
|
||||||
this.dataInitialized = false;
|
this.dataInitialized = false;
|
||||||
this.pendingDataCheck = null;
|
this.pendingDataCheck = null;
|
||||||
|
this.pendingInputDataCheck = null;
|
||||||
|
this.inputDataLoaded = false;
|
||||||
this.imageCache = new Map();
|
this.imageCache = new Map();
|
||||||
this.requestSaveState = () => { };
|
this.requestSaveState = () => { };
|
||||||
this.outputAreaShape = null;
|
this.outputAreaShape = null;
|
||||||
@@ -372,6 +374,10 @@ export class Canvas {
|
|||||||
return widget ? widget.value : false;
|
return widget ? widget.value : false;
|
||||||
};
|
};
|
||||||
const handleExecutionStart = () => {
|
const handleExecutionStart = () => {
|
||||||
|
// Check for input data when execution starts, but don't reset the flag
|
||||||
|
log.debug('Execution started, checking for input data...');
|
||||||
|
// On start, only allow images; mask should load on mask-connect or after execution completes
|
||||||
|
this.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: 'execution_start' });
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
lastExecutionStartTime = Date.now();
|
lastExecutionStartTime = Date.now();
|
||||||
// Store a snapshot of the context for the upcoming batch
|
// Store a snapshot of the context for the upcoming batch
|
||||||
@@ -394,6 +400,9 @@ export class Canvas {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleExecutionSuccess = async () => {
|
const handleExecutionSuccess = async () => {
|
||||||
|
// Always check for input data after execution completes
|
||||||
|
log.debug('Execution success, checking for input data...');
|
||||||
|
await this.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: 'execution_success' });
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
log.info('Auto-refresh triggered, importing latest images.');
|
log.info('Auto-refresh triggered, importing latest images.');
|
||||||
if (!this.pendingBatchContext) {
|
if (!this.pendingBatchContext) {
|
||||||
|
|||||||
537
js/CanvasIO.js
537
js/CanvasIO.js
@@ -2,6 +2,7 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
|||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||||
|
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
|
||||||
const log = createModuleLogger('CanvasIO');
|
const log = createModuleLogger('CanvasIO');
|
||||||
export class CanvasIO {
|
export class CanvasIO {
|
||||||
constructor(canvas) {
|
constructor(canvas) {
|
||||||
@@ -247,17 +248,12 @@ export class CanvasIO {
|
|||||||
async addInputToCanvas(inputImage, inputMask) {
|
async addInputToCanvas(inputImage, inputMask) {
|
||||||
try {
|
try {
|
||||||
log.debug("Adding input to canvas:", { inputImage });
|
log.debug("Adding input to canvas:", { inputImage });
|
||||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
// Use unified tensorToImageData for RGB image
|
||||||
if (!tempCtx)
|
const imageData = tensorToImageData(inputImage, 'rgb');
|
||||||
throw new Error("Could not create temp context");
|
if (!imageData)
|
||||||
const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), inputImage.width, inputImage.height);
|
throw new Error("Failed to convert input image tensor");
|
||||||
tempCtx.putImageData(imgData, 0, 0);
|
// Create HTMLImageElement from ImageData
|
||||||
const image = new Image();
|
const image = await createImageFromImageData(imageData);
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
image.onload = resolve;
|
|
||||||
image.onerror = reject;
|
|
||||||
image.src = tempCanvas.toDataURL();
|
|
||||||
});
|
|
||||||
const bounds = this.canvas.outputAreaBounds;
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.height / inputImage.height * 0.8);
|
const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.height / inputImage.height * 0.8);
|
||||||
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
|
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
|
||||||
@@ -283,17 +279,10 @@ export class CanvasIO {
|
|||||||
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
||||||
throw new Error("Invalid tensor data");
|
throw new Error("Invalid tensor data");
|
||||||
}
|
}
|
||||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
|
const imageData = tensorToImageData(tensor, 'rgb');
|
||||||
if (!ctx)
|
if (!imageData)
|
||||||
throw new Error("Could not create canvas context");
|
throw new Error("Failed to convert tensor to image data");
|
||||||
const imageData = new ImageData(new Uint8ClampedArray(tensor.data), tensor.width, tensor.height);
|
return await createImageFromImageData(imageData);
|
||||||
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) {
|
catch (error) {
|
||||||
log.error("Error converting tensor to image:", error);
|
log.error("Error converting tensor to image:", error);
|
||||||
@@ -314,12 +303,26 @@ export class CanvasIO {
|
|||||||
async initNodeData() {
|
async initNodeData() {
|
||||||
try {
|
try {
|
||||||
log.info("Starting node data initialization...");
|
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) {
|
if (!this.canvas.node || !this.canvas.node.inputs) {
|
||||||
log.debug("Node or inputs not ready");
|
log.debug("Node or inputs not ready");
|
||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
}
|
}
|
||||||
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||||
const imageLinkId = 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];
|
const imageData = window.app.nodeOutputs[imageLinkId];
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
log.debug("Found image data:", imageData);
|
log.debug("Found image data:", imageData);
|
||||||
@@ -331,6 +334,10 @@ export class CanvasIO {
|
|||||||
return this.scheduleDataCheck();
|
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) {
|
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
||||||
const maskLinkId = this.canvas.node.inputs[1].link;
|
const maskLinkId = this.canvas.node.inputs[1].link;
|
||||||
const maskData = window.app.nodeOutputs[maskLinkId];
|
const maskData = window.app.nodeOutputs[maskLinkId];
|
||||||
@@ -345,6 +352,390 @@ export class CanvasIO {
|
|||||||
return this.scheduleDataCheck();
|
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() {
|
scheduleDataCheck() {
|
||||||
if (this.canvas.pendingDataCheck) {
|
if (this.canvas.pendingDataCheck) {
|
||||||
clearTimeout(this.canvas.pendingDataCheck);
|
clearTimeout(this.canvas.pendingDataCheck);
|
||||||
@@ -423,51 +814,10 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
convertTensorToImageData(tensor) {
|
convertTensorToImageData(tensor) {
|
||||||
try {
|
return tensorToImageData(tensor, 'rgb');
|
||||||
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) {
|
async createImageFromData(imageData) {
|
||||||
return new Promise((resolve, reject) => {
|
return createImageFromImageData(imageData);
|
||||||
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) {
|
async processMaskData(maskData) {
|
||||||
try {
|
try {
|
||||||
@@ -527,12 +877,7 @@ export class CanvasIO {
|
|||||||
log.info(`Received ${result.images.length} new images, adding to canvas.`);
|
log.info(`Received ${result.images.length} new images, adding to canvas.`);
|
||||||
const newLayers = [];
|
const newLayers = [];
|
||||||
for (const imageData of result.images) {
|
for (const imageData of result.images) {
|
||||||
const img = new Image();
|
const img = await createImageFromSource(imageData);
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
img.onload = resolve;
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = imageData;
|
|
||||||
});
|
|
||||||
let processedImage = img;
|
let processedImage = img;
|
||||||
// If there's a custom shape, clip the image to that shape
|
// If there's a custom shape, clip the image to that shape
|
||||||
if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) {
|
if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) {
|
||||||
@@ -559,33 +904,27 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async clipImageToShape(image, shape) {
|
async clipImageToShape(image, shape) {
|
||||||
return new Promise((resolve, reject) => {
|
const { canvas, ctx } = createCanvas(image.width, image.height);
|
||||||
const { canvas, ctx } = createCanvas(image.width, image.height);
|
if (!ctx) {
|
||||||
if (!ctx) {
|
throw new Error("Could not create canvas context for clipping");
|
||||||
reject(new Error("Could not create canvas context for clipping"));
|
}
|
||||||
return;
|
// Draw the image first
|
||||||
}
|
ctx.drawImage(image, 0, 0);
|
||||||
// Draw the image first
|
// Calculate custom shape position accounting for extensions
|
||||||
ctx.drawImage(image, 0, 0);
|
// Custom shape should maintain its relative position within the original canvas area
|
||||||
// Calculate custom shape position accounting for extensions
|
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
// Custom shape should maintain its relative position within the original canvas area
|
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
||||||
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
||||||
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
// Create a clipping mask using the shape with extension offset
|
||||||
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
// Create a clipping mask using the shape with extension offset
|
ctx.beginPath();
|
||||||
ctx.globalCompositeOperation = 'destination-in';
|
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
||||||
ctx.beginPath();
|
for (let i = 1; i < shape.points.length; i++) {
|
||||||
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].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();
|
||||||
ctx.closePath();
|
// Create a new image from the clipped canvas
|
||||||
ctx.fill();
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -652,8 +652,8 @@ export class CanvasRenderer {
|
|||||||
this.updateStrokeOverlaySize();
|
this.updateStrokeOverlaySize();
|
||||||
// Position above main canvas but below cursor overlay
|
// Position above main canvas but below cursor overlay
|
||||||
this.strokeOverlayCanvas.style.position = 'absolute';
|
this.strokeOverlayCanvas.style.position = 'absolute';
|
||||||
this.strokeOverlayCanvas.style.left = '1px';
|
this.strokeOverlayCanvas.style.left = '0px';
|
||||||
this.strokeOverlayCanvas.style.top = '1px';
|
this.strokeOverlayCanvas.style.top = '0px';
|
||||||
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
||||||
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
||||||
// Opacity is now controlled by MaskTool.previewOpacity
|
// Opacity is now controlled by MaskTool.previewOpacity
|
||||||
|
|||||||
@@ -404,12 +404,10 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
}
|
}
|
||||||
if (this.maskUndoStack.length > 0) {
|
if (this.maskUndoStack.length > 0) {
|
||||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
this.canvas.maskTool.restoreMaskFromSavedState(prevState);
|
||||||
if (maskCtx) {
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
maskCtx.drawImage(prevState, 0, 0);
|
|
||||||
}
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
this.canvas.updateHistoryButtons();
|
this.canvas.updateHistoryButtons();
|
||||||
@@ -420,12 +418,10 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
const nextState = this.maskRedoStack.pop();
|
const nextState = this.maskRedoStack.pop();
|
||||||
if (nextState) {
|
if (nextState) {
|
||||||
this.maskUndoStack.push(nextState);
|
this.maskUndoStack.push(nextState);
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
this.canvas.maskTool.restoreMaskFromSavedState(nextState);
|
||||||
if (maskCtx) {
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
maskCtx.drawImage(nextState, 0, 0);
|
|
||||||
}
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
this.canvas.updateHistoryButtons();
|
this.canvas.updateHistoryButtons();
|
||||||
|
|||||||
151
js/CanvasView.js
151
js/CanvasView.js
@@ -911,7 +911,9 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
height: "100%"
|
height: "100%"
|
||||||
}
|
}
|
||||||
}, [controlPanel, canvasContainer, layersPanelContainer]);
|
}, [controlPanel, canvasContainer, layersPanelContainer]);
|
||||||
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
if (node.addDOMWidget) {
|
||||||
|
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||||
|
}
|
||||||
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`);
|
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`);
|
||||||
let backdrop = null;
|
let backdrop = null;
|
||||||
let originalParent = null;
|
let originalParent = null;
|
||||||
@@ -1000,7 +1002,11 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
if (!window.canvasExecutionStates) {
|
if (!window.canvasExecutionStates) {
|
||||||
window.canvasExecutionStates = new Map();
|
window.canvasExecutionStates = new Map();
|
||||||
}
|
}
|
||||||
node.canvasWidget = canvas;
|
// Store the entire widget object, not just the canvas
|
||||||
|
node.canvasWidget = {
|
||||||
|
canvas: canvas,
|
||||||
|
panel: controlPanel
|
||||||
|
};
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
canvas.loadInitialState();
|
canvas.loadInitialState();
|
||||||
if (canvas.canvasLayersPanel) {
|
if (canvas.canvasLayersPanel) {
|
||||||
@@ -1017,7 +1023,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
if (canvas && canvas.setPreviewVisibility) {
|
if (canvas && canvas.setPreviewVisibility) {
|
||||||
canvas.setPreviewVisibility(value);
|
canvas.setPreviewVisibility(value);
|
||||||
}
|
}
|
||||||
if (node.graph && node.graph.canvas) {
|
if (node.graph && node.graph.canvas && node.setDirtyCanvas) {
|
||||||
node.setDirtyCanvas(true, true);
|
node.setDirtyCanvas(true, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1096,9 +1102,144 @@ app.registerExtension({
|
|||||||
const canvasWidget = await createCanvasWidget(this, null, app);
|
const canvasWidget = await createCanvasWidget(this, null, app);
|
||||||
canvasNodeInstances.set(this.id, canvasWidget);
|
canvasNodeInstances.set(this.id, canvasWidget);
|
||||||
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
||||||
|
// Store the canvas widget on the node
|
||||||
|
this.canvasWidget = canvasWidget;
|
||||||
|
// Check if there are already connected inputs
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.setDirtyCanvas(true, true);
|
if (this.inputs && this.inputs.length > 0) {
|
||||||
}, 100);
|
// Check if input_image (index 0) is connected
|
||||||
|
if (this.inputs[0] && this.inputs[0].link) {
|
||||||
|
log.info("Input image already connected on node creation, checking for data...");
|
||||||
|
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||||
|
canvasWidget.canvas.inputDataLoaded = false;
|
||||||
|
// Only allow images on init; mask should load only on mask connect or execution
|
||||||
|
canvasWidget.canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "init_image_connected" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.setDirtyCanvas) {
|
||||||
|
this.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
// Add onConnectionsChange handler to detect when inputs are connected
|
||||||
|
nodeType.prototype.onConnectionsChange = function (type, index, connected, link_info) {
|
||||||
|
log.info(`onConnectionsChange called: type=${type}, index=${index}, connected=${connected}`, link_info);
|
||||||
|
// Check if this is an input connection (type 1 = INPUT)
|
||||||
|
if (type === 1) {
|
||||||
|
// Get the canvas widget - it might be in different places
|
||||||
|
const canvasWidget = this.canvasWidget;
|
||||||
|
const canvas = canvasWidget?.canvas || canvasWidget;
|
||||||
|
if (!canvas || !canvas.canvasIO) {
|
||||||
|
log.warn("Canvas not ready in onConnectionsChange, scheduling retry...");
|
||||||
|
// Retry multiple times with increasing delays
|
||||||
|
const retryDelays = [500, 1000, 2000];
|
||||||
|
let retryCount = 0;
|
||||||
|
const tryAgain = () => {
|
||||||
|
const retryCanvas = this.canvasWidget?.canvas || this.canvasWidget;
|
||||||
|
if (retryCanvas && retryCanvas.canvasIO) {
|
||||||
|
log.info("Canvas now ready, checking for input data...");
|
||||||
|
if (connected) {
|
||||||
|
retryCanvas.inputDataLoaded = false;
|
||||||
|
// Respect which input triggered the connection:
|
||||||
|
const opts = (index === 1)
|
||||||
|
? { allowImage: false, allowMask: true, reason: "mask_connect" }
|
||||||
|
: { allowImage: true, allowMask: false, reason: "image_connect" };
|
||||||
|
retryCanvas.canvasIO.checkForInputData(opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (retryCount < retryDelays.length) {
|
||||||
|
log.warn(`Canvas still not ready, retry ${retryCount + 1}/${retryDelays.length}...`);
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.error("Canvas failed to initialize after multiple retries");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Handle input_image connection (index 0)
|
||||||
|
if (index === 0) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input image connected, marking for data check...");
|
||||||
|
// Reset the input data loaded flag to allow loading the new connection
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
// Also reset the last loaded image source and link ID to allow the new image
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
// Mark that we have a pending input connection
|
||||||
|
canvas.hasPendingInputConnection = true;
|
||||||
|
// If mask input is not connected and a mask was auto-applied from input_mask before, clear it now
|
||||||
|
if (!(this.inputs && this.inputs[1] && this.inputs[1].link)) {
|
||||||
|
if (canvas.maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
canvas.maskTool.clear();
|
||||||
|
canvas.render();
|
||||||
|
canvas.maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask because input_image connected without input_mask");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after connection...");
|
||||||
|
// Only load images here; masks should not auto-load on image connect
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "image_connect" });
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info("Input image disconnected");
|
||||||
|
canvas.hasPendingInputConnection = false;
|
||||||
|
// Reset when disconnected so a new connection can load
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle input_mask connection (index 1)
|
||||||
|
if (index === 1) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input mask connected");
|
||||||
|
// DON'T clear existing mask when connecting a new input
|
||||||
|
// Reset the loaded mask link ID to allow loading from the new connection
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
// Mark that we have a pending mask connection
|
||||||
|
canvas.hasPendingMaskConnection = true;
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after mask connection...");
|
||||||
|
// Only load mask here if it's immediately available from the connected node
|
||||||
|
// Don't load stale masks from backend storage
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: false, allowMask: true, reason: "mask_connect" });
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info("Input mask disconnected");
|
||||||
|
canvas.hasPendingMaskConnection = false;
|
||||||
|
// If the current mask came from input_mask, clear it to avoid affecting images when mask is not connected
|
||||||
|
if (canvas.maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
canvas.maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask due to mask input disconnection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Add onExecuted handler to check for input data after workflow execution
|
||||||
|
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||||
|
nodeType.prototype.onExecuted = function (message) {
|
||||||
|
log.info("Node executed, checking for input data...");
|
||||||
|
const canvas = this.canvasWidget?.canvas || this.canvasWidget;
|
||||||
|
if (canvas && canvas.canvasIO) {
|
||||||
|
// Don't reset inputDataLoaded - just check for new data
|
||||||
|
// On execution we allow both image and mask to load
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: "execution" });
|
||||||
|
}
|
||||||
|
// Call original if it exists
|
||||||
|
if (originalOnExecuted) {
|
||||||
|
originalOnExecuted.apply(this, arguments);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const onRemoved = nodeType.prototype.onRemoved;
|
const onRemoved = nodeType.prototype.onRemoved;
|
||||||
nodeType.prototype.onRemoved = function () {
|
nodeType.prototype.onRemoved = function () {
|
||||||
|
|||||||
@@ -424,7 +424,6 @@ export class MaskEditorIntegration {
|
|||||||
boundsPos: { x: bounds.x, y: bounds.y },
|
boundsPos: { x: bounds.x, y: bounds.y },
|
||||||
maskSize: { width: bounds.width, height: bounds.height }
|
maskSize: { width: bounds.width, height: bounds.height }
|
||||||
});
|
});
|
||||||
// Use the chunk system instead of direct canvas manipulation
|
|
||||||
this.maskTool.setMask(maskAsImage);
|
this.maskTool.setMask(maskAsImage);
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
await updateNodePreview(this.canvas, this.node, true);
|
await updateNodePreview(this.canvas, this.node, true);
|
||||||
|
|||||||
@@ -1352,6 +1352,23 @@ export class MaskTool {
|
|||||||
this.canvasInstance.render();
|
this.canvasInstance.render();
|
||||||
log.info("Cleared all mask data from all chunks");
|
log.info("Cleared all mask data from all chunks");
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Clears all chunks and restores mask from saved state
|
||||||
|
* This is used during undo/redo operations to ensure clean state restoration
|
||||||
|
*/
|
||||||
|
restoreMaskFromSavedState(savedMaskCanvas) {
|
||||||
|
// First, clear ALL chunks to ensure no leftover data
|
||||||
|
this.clearAllMaskChunks();
|
||||||
|
// Now apply the saved mask state to chunks
|
||||||
|
if (savedMaskCanvas.width > 0 && savedMaskCanvas.height > 0) {
|
||||||
|
// Apply the saved mask to the chunk system at the correct position
|
||||||
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
|
this.applyMaskCanvasToChunks(savedMaskCanvas, this.x, this.y);
|
||||||
|
}
|
||||||
|
// Update the active mask canvas to show the restored state
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
log.debug("Restored mask from saved state with clean chunk system");
|
||||||
|
}
|
||||||
getMask() {
|
getMask() {
|
||||||
// Return the current active mask canvas which shows all chunks
|
// Return the current active mask canvas which shows all chunks
|
||||||
// Only update if there are pending changes to avoid unnecessary redraws
|
// Only update if there are pending changes to avoid unnecessary redraws
|
||||||
@@ -1445,13 +1462,44 @@ export class MaskTool {
|
|||||||
this.isOverlayVisible = !this.isOverlayVisible;
|
this.isOverlayVisible = !this.isOverlayVisible;
|
||||||
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
||||||
}
|
}
|
||||||
setMask(image) {
|
setMask(image, isFromInputMask = false) {
|
||||||
// Clear existing mask chunks in the output area first
|
|
||||||
const bounds = this.canvasInstance.outputAreaBounds;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
this.clearMaskInArea(bounds.x, bounds.y, image.width, image.height);
|
if (isFromInputMask) {
|
||||||
// Add the new mask using the chunk system
|
// For INPUT MASK - process black background to transparent using luminance
|
||||||
this.addMask(image);
|
// Center like input images
|
||||||
log.info(`MaskTool set new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
const centerX = bounds.x + (bounds.width - image.width) / 2;
|
||||||
|
const centerY = bounds.y + (bounds.height - image.height) / 2;
|
||||||
|
// Prepare mask where alpha = luminance (white = applied, black = transparent)
|
||||||
|
const { canvas: maskCanvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("Could not create mask processing context");
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
const imgData = ctx.getImageData(0, 0, image.width, image.height);
|
||||||
|
const data = imgData.data;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||||
|
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||||
|
data[i] = 255; // force white color (color channels ignored downstream)
|
||||||
|
data[i + 1] = 255;
|
||||||
|
data[i + 2] = 255;
|
||||||
|
data[i + 3] = lum; // alpha encodes mask strength: white -> strong, black -> 0
|
||||||
|
}
|
||||||
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
// Clear target area and apply to chunked system at centered position
|
||||||
|
this.clearMaskInArea(centerX, centerY, image.width, image.height);
|
||||||
|
this.applyMaskCanvasToChunks(maskCanvas, centerX, centerY);
|
||||||
|
// Refresh state and UI
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
this.canvasInstance.canvasState.saveMaskState();
|
||||||
|
this.canvasInstance.render();
|
||||||
|
log.info(`MaskTool set INPUT MASK at centered position (${centerX}, ${centerY}) using luminance as alpha`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// For SAM Detector and other sources - just clear and add without processing
|
||||||
|
this.clearMaskInArea(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||||
|
this.addMask(image);
|
||||||
|
log.info(`MaskTool set mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Clears mask data in a specific area by clearing affected chunks
|
* Clears mask data in a specific area by clearing affected chunks
|
||||||
|
|||||||
@@ -242,35 +242,61 @@ async function handleSAMDetectorResult(node, resultImage) {
|
|||||||
// Try to reload the image with a fresh request
|
// Try to reload the image with a fresh request
|
||||||
log.debug("Attempting to reload SAM result image");
|
log.debug("Attempting to reload SAM result image");
|
||||||
const originalSrc = resultImage.src;
|
const originalSrc = resultImage.src;
|
||||||
// Add cache-busting parameter to force fresh load
|
// Check if it's a data URL (base64) - don't add parameters to data URLs
|
||||||
const url = new URL(originalSrc);
|
if (originalSrc.startsWith('data:')) {
|
||||||
url.searchParams.set('_t', Date.now().toString());
|
log.debug("Image is a data URL, skipping reload with parameters");
|
||||||
await new Promise((resolve, reject) => {
|
// For data URLs, just ensure the image is loaded
|
||||||
const img = new Image();
|
if (!resultImage.complete || resultImage.naturalWidth === 0) {
|
||||||
img.crossOrigin = "anonymous";
|
await new Promise((resolve, reject) => {
|
||||||
img.onload = () => {
|
const img = new Image();
|
||||||
// Copy the loaded image data to the original image
|
img.onload = () => {
|
||||||
resultImage.src = img.src;
|
resultImage.width = img.width;
|
||||||
resultImage.width = img.width;
|
resultImage.height = img.height;
|
||||||
resultImage.height = img.height;
|
log.debug("Data URL image loaded successfully", {
|
||||||
log.debug("SAM result image reloaded successfully", {
|
width: img.width,
|
||||||
width: img.width,
|
height: img.height
|
||||||
height: img.height,
|
});
|
||||||
originalSrc: originalSrc,
|
resolve(img);
|
||||||
newSrc: img.src
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to load data URL image", error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = originalSrc; // Use original src without modifications
|
||||||
});
|
});
|
||||||
resolve(img);
|
}
|
||||||
};
|
}
|
||||||
img.onerror = (error) => {
|
else {
|
||||||
log.error("Failed to reload SAM result image", {
|
// For regular URLs, add cache-busting parameter
|
||||||
originalSrc: originalSrc,
|
const url = new URL(originalSrc);
|
||||||
newSrc: url.toString(),
|
url.searchParams.set('_t', Date.now().toString());
|
||||||
error: error
|
await new Promise((resolve, reject) => {
|
||||||
});
|
const img = new Image();
|
||||||
reject(error);
|
img.crossOrigin = "anonymous";
|
||||||
};
|
img.onload = () => {
|
||||||
img.src = url.toString();
|
// Copy the loaded image data to the original image
|
||||||
});
|
resultImage.src = img.src;
|
||||||
|
resultImage.width = img.width;
|
||||||
|
resultImage.height = img.height;
|
||||||
|
log.debug("SAM result image reloaded successfully", {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: img.src
|
||||||
|
});
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to reload SAM result image", {
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: url.toString(),
|
||||||
|
error: error
|
||||||
|
});
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = url.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -290,27 +316,37 @@ async function handleSAMDetectorResult(node, resultImage) {
|
|||||||
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||||
log.debug("Checking canvas and maskTool availability", {
|
log.debug("Checking canvas and maskTool availability", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasCanvasProperty: !!canvas.canvas,
|
||||||
|
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
|
||||||
hasMaskTool: !!canvas.maskTool,
|
hasMaskTool: !!canvas.maskTool,
|
||||||
|
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
|
||||||
maskToolType: typeof canvas.maskTool,
|
maskToolType: typeof canvas.maskTool,
|
||||||
|
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas)
|
canvasKeys: Object.keys(canvas)
|
||||||
});
|
});
|
||||||
if (!canvas.maskTool) {
|
// Get the actual Canvas object and its maskTool
|
||||||
|
const actualCanvas = canvas.canvas || canvas;
|
||||||
|
const maskTool = actualCanvas.maskTool;
|
||||||
|
if (!maskTool) {
|
||||||
log.error("MaskTool is not available. Canvas state:", {
|
log.error("MaskTool is not available. Canvas state:", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasActualCanvas: !!actualCanvas,
|
||||||
canvasConstructor: canvas.constructor.name,
|
canvasConstructor: canvas.constructor.name,
|
||||||
|
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas),
|
canvasKeys: Object.keys(canvas),
|
||||||
maskToolValue: canvas.maskTool
|
actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
|
||||||
|
maskToolValue: maskTool
|
||||||
});
|
});
|
||||||
throw new Error("Mask tool not available or not initialized");
|
throw new Error("Mask tool not available or not initialized");
|
||||||
}
|
}
|
||||||
log.debug("Applying SAM mask to canvas using addMask method");
|
log.debug("Applying SAM mask to canvas using setMask method");
|
||||||
// Use the addMask method which overlays on existing mask without clearing it
|
// Use the setMask method which clears existing mask and sets new one
|
||||||
canvas.maskTool.addMask(maskAsImage);
|
maskTool.setMask(maskAsImage);
|
||||||
// Update canvas and save state (same as MaskEditorIntegration)
|
// Update canvas and save state (same as MaskEditorIntegration)
|
||||||
canvas.render();
|
actualCanvas.render();
|
||||||
canvas.saveState();
|
actualCanvas.saveState();
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
await updateNodePreview(canvas, node, true);
|
await updateNodePreview(actualCanvas, node, true);
|
||||||
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
||||||
// Show success notification
|
// Show success notification
|
||||||
showSuccessNotification("SAM Detector mask applied to LayerForge!");
|
showSuccessNotification("SAM Detector mask applied to LayerForge!");
|
||||||
@@ -340,13 +376,20 @@ export function setupSAMDetectorHook(node, options) {
|
|||||||
try {
|
try {
|
||||||
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||||
// Automatically send canvas to clipspace and start monitoring
|
// Automatically send canvas to clipspace and start monitoring
|
||||||
if (node.canvasWidget && node.canvasWidget.canvas) {
|
if (node.canvasWidget) {
|
||||||
const canvas = node.canvasWidget; // canvasWidget IS the Canvas object
|
const canvasWidget = node.canvasWidget;
|
||||||
// Use ImageUploadUtils to upload canvas
|
const canvas = canvasWidget.canvas || canvasWidget; // Get actual Canvas object
|
||||||
|
// Use ImageUploadUtils to upload canvas and get server URL (Impact Pack compatibility)
|
||||||
const uploadResult = await uploadCanvasAsImage(canvas, {
|
const uploadResult = await uploadCanvasAsImage(canvas, {
|
||||||
filenamePrefix: 'layerforge-sam',
|
filenamePrefix: 'layerforge-sam',
|
||||||
nodeId: node.id
|
nodeId: node.id
|
||||||
});
|
});
|
||||||
|
log.debug("Uploaded canvas for SAM Detector", {
|
||||||
|
filename: uploadResult.filename,
|
||||||
|
imageUrl: uploadResult.imageUrl,
|
||||||
|
width: uploadResult.imageElement.width,
|
||||||
|
height: uploadResult.imageElement.height
|
||||||
|
});
|
||||||
// Set the image to the node for clipspace
|
// Set the image to the node for clipspace
|
||||||
node.imgs = [uploadResult.imageElement];
|
node.imgs = [uploadResult.imageElement];
|
||||||
node.clipspaceImg = uploadResult.imageElement;
|
node.clipspaceImg = uploadResult.imageElement;
|
||||||
|
|||||||
@@ -314,3 +314,102 @@ export function canvasToMaskImage(canvas) {
|
|||||||
img.src = canvas.toDataURL();
|
img.src = canvas.toDataURL();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Scales an image to fit within specified bounds while maintaining aspect ratio
|
||||||
|
* @param image - Image to scale
|
||||||
|
* @param targetWidth - Target width to fit within
|
||||||
|
* @param targetHeight - Target height to fit within
|
||||||
|
* @returns Promise with scaled Image element
|
||||||
|
*/
|
||||||
|
export async function scaleImageToFit(image, targetWidth, targetHeight) {
|
||||||
|
const scale = Math.min(targetWidth / image.width, targetHeight / image.height);
|
||||||
|
const scaledWidth = Math.max(1, Math.round(image.width * scale));
|
||||||
|
const scaledHeight = Math.max(1, Math.round(image.height * scale));
|
||||||
|
const { canvas, ctx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("Could not create scaled image context");
|
||||||
|
ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const scaledImg = new Image();
|
||||||
|
scaledImg.onload = () => resolve(scaledImg);
|
||||||
|
scaledImg.onerror = reject;
|
||||||
|
scaledImg.src = canvas.toDataURL();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Unified tensor to image data conversion
|
||||||
|
* Handles both RGB images and grayscale masks
|
||||||
|
* @param tensor - Input tensor data
|
||||||
|
* @param mode - 'rgb' for images or 'grayscale' for masks
|
||||||
|
* @returns ImageData object
|
||||||
|
*/
|
||||||
|
export function tensorToImageData(tensor, mode = 'rgb') {
|
||||||
|
try {
|
||||||
|
const shape = tensor.shape;
|
||||||
|
const height = shape[1];
|
||||||
|
const width = shape[2];
|
||||||
|
const channels = shape[3] || 1; // Default to 1 for masks
|
||||||
|
log.debug("Converting tensor:", { shape, channels, mode });
|
||||||
|
const imageData = new ImageData(width, height);
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
|
const flatData = tensor.data;
|
||||||
|
const pixelCount = width * height;
|
||||||
|
const min = tensor.min_val ?? 0;
|
||||||
|
const max = tensor.max_val ?? 1;
|
||||||
|
const denom = (max - min) || 1;
|
||||||
|
for (let i = 0; i < pixelCount; i++) {
|
||||||
|
const pixelIndex = i * 4;
|
||||||
|
const tensorIndex = i * channels;
|
||||||
|
let lum;
|
||||||
|
if (mode === 'grayscale' || channels === 1) {
|
||||||
|
lum = flatData[tensorIndex];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Compute luminance for RGB
|
||||||
|
const r = flatData[tensorIndex + 0] ?? 0;
|
||||||
|
const g = flatData[tensorIndex + 1] ?? 0;
|
||||||
|
const b = flatData[tensorIndex + 2] ?? 0;
|
||||||
|
lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
}
|
||||||
|
let norm = (lum - min) / denom;
|
||||||
|
if (!isFinite(norm))
|
||||||
|
norm = 0;
|
||||||
|
norm = Math.max(0, Math.min(1, norm));
|
||||||
|
const value = Math.round(norm * 255);
|
||||||
|
if (mode === 'grayscale') {
|
||||||
|
// For masks: RGB = value, A = 255 (MaskTool reads luminance)
|
||||||
|
data[pixelIndex] = value;
|
||||||
|
data[pixelIndex + 1] = value;
|
||||||
|
data[pixelIndex + 2] = value;
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// For images: RGB from channels, A = 255
|
||||||
|
for (let c = 0; c < Math.min(3, channels); c++) {
|
||||||
|
const channelValue = flatData[tensorIndex + c];
|
||||||
|
const channelNorm = (channelValue - min) / denom;
|
||||||
|
data[pixelIndex + c] = Math.round(channelNorm * 255);
|
||||||
|
}
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageData.data.set(data);
|
||||||
|
return imageData;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error("Error converting tensor:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Creates an HTMLImageElement from ImageData
|
||||||
|
* @param imageData - Input ImageData
|
||||||
|
* @returns Promise with HTMLImageElement
|
||||||
|
*/
|
||||||
|
export async function createImageFromImageData(imageData) {
|
||||||
|
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);
|
||||||
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "layerforge"
|
name = "layerforge"
|
||||||
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
||||||
version = "1.5.5"
|
version = "1.5.6"
|
||||||
license = { text = "MIT License" }
|
license = { text = "MIT License" }
|
||||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ export class Canvas {
|
|||||||
onStateChange: (() => void) | undefined;
|
onStateChange: (() => void) | undefined;
|
||||||
pendingBatchContext: any;
|
pendingBatchContext: any;
|
||||||
pendingDataCheck: number | null;
|
pendingDataCheck: number | null;
|
||||||
|
pendingInputDataCheck: number | null;
|
||||||
|
inputDataLoaded: boolean;
|
||||||
|
lastLoadedImageSrc?: string;
|
||||||
|
lastLoadedLinkId?: number;
|
||||||
|
lastLoadedMaskLinkId?: number;
|
||||||
previewVisible: boolean;
|
previewVisible: boolean;
|
||||||
requestSaveState: () => void;
|
requestSaveState: () => void;
|
||||||
viewport: Viewport;
|
viewport: Viewport;
|
||||||
@@ -138,6 +143,8 @@ export class Canvas {
|
|||||||
|
|
||||||
this.dataInitialized = false;
|
this.dataInitialized = false;
|
||||||
this.pendingDataCheck = null;
|
this.pendingDataCheck = null;
|
||||||
|
this.pendingInputDataCheck = null;
|
||||||
|
this.inputDataLoaded = false;
|
||||||
this.imageCache = new Map();
|
this.imageCache = new Map();
|
||||||
|
|
||||||
this.requestSaveState = () => {};
|
this.requestSaveState = () => {};
|
||||||
@@ -483,6 +490,11 @@ export class Canvas {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExecutionStart = () => {
|
const handleExecutionStart = () => {
|
||||||
|
// Check for input data when execution starts, but don't reset the flag
|
||||||
|
log.debug('Execution started, checking for input data...');
|
||||||
|
// On start, only allow images; mask should load on mask-connect or after execution completes
|
||||||
|
this.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: 'execution_start' });
|
||||||
|
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
lastExecutionStartTime = Date.now();
|
lastExecutionStartTime = Date.now();
|
||||||
// Store a snapshot of the context for the upcoming batch
|
// Store a snapshot of the context for the upcoming batch
|
||||||
@@ -506,6 +518,10 @@ export class Canvas {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExecutionSuccess = async () => {
|
const handleExecutionSuccess = async () => {
|
||||||
|
// Always check for input data after execution completes
|
||||||
|
log.debug('Execution success, checking for input data...');
|
||||||
|
await this.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: 'execution_success' });
|
||||||
|
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
log.info('Auto-refresh triggered, importing latest images.');
|
log.info('Auto-refresh triggered, importing latest images.');
|
||||||
|
|
||||||
|
|||||||
603
src/CanvasIO.ts
603
src/CanvasIO.ts
@@ -2,6 +2,7 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
|||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||||
|
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
|
||||||
import type { Canvas } from './Canvas';
|
import type { Canvas } from './Canvas';
|
||||||
import type { Layer, Shape } from './types';
|
import type { Layer, Shape } from './types';
|
||||||
|
|
||||||
@@ -282,22 +283,12 @@ export class CanvasIO {
|
|||||||
try {
|
try {
|
||||||
log.debug("Adding input to canvas:", { inputImage });
|
log.debug("Adding input to canvas:", { inputImage });
|
||||||
|
|
||||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
// Use unified tensorToImageData for RGB image
|
||||||
if (!tempCtx) throw new Error("Could not create temp context");
|
const imageData = tensorToImageData(inputImage, 'rgb');
|
||||||
|
if (!imageData) throw new Error("Failed to convert input image tensor");
|
||||||
|
|
||||||
const imgData = new ImageData(
|
// Create HTMLImageElement from ImageData
|
||||||
new Uint8ClampedArray(inputImage.data),
|
const image = await createImageFromImageData(imageData);
|
||||||
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 bounds = this.canvas.outputAreaBounds;
|
||||||
const scale = Math.min(
|
const scale = Math.min(
|
||||||
@@ -333,23 +324,10 @@ export class CanvasIO {
|
|||||||
throw new Error("Invalid tensor data");
|
throw new Error("Invalid tensor data");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
|
const imageData = tensorToImageData(tensor, 'rgb');
|
||||||
if (!ctx) throw new Error("Could not create canvas context");
|
if (!imageData) throw new Error("Failed to convert tensor to image data");
|
||||||
|
|
||||||
const imageData = new ImageData(
|
return await createImageFromImageData(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) {
|
} catch (error) {
|
||||||
log.error("Error converting tensor to image:", error);
|
log.error("Error converting tensor to image:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -372,6 +350,16 @@ export class CanvasIO {
|
|||||||
try {
|
try {
|
||||||
log.info("Starting node data initialization...");
|
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 as any).inputs) {
|
if (!this.canvas.node || !(this.canvas.node as any).inputs) {
|
||||||
log.debug("Node or inputs not ready");
|
log.debug("Node or inputs not ready");
|
||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
@@ -379,6 +367,14 @@ export class CanvasIO {
|
|||||||
|
|
||||||
if ((this.canvas.node as any).inputs[0] && (this.canvas.node as any).inputs[0].link) {
|
if ((this.canvas.node as any).inputs[0] && (this.canvas.node as any).inputs[0].link) {
|
||||||
const imageLinkId = (this.canvas.node as any).inputs[0].link;
|
const imageLinkId = (this.canvas.node as any).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 as any).app.nodeOutputs[imageLinkId];
|
const imageData = (window as any).app.nodeOutputs[imageLinkId];
|
||||||
|
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
@@ -389,6 +385,9 @@ export class CanvasIO {
|
|||||||
log.debug("Image data not available yet");
|
log.debug("Image data not available yet");
|
||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No input connected, mark as initialized to stop repeated checks
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((this.canvas.node as any).inputs[1] && (this.canvas.node as any).inputs[1].link) {
|
if ((this.canvas.node as any).inputs[1] && (this.canvas.node as any).inputs[1].link) {
|
||||||
@@ -407,6 +406,439 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkForInputData(options?: { allowImage?: boolean; allowMask?: boolean; reason?: string }): Promise<void> {
|
||||||
|
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 as any).graph;
|
||||||
|
|
||||||
|
// Always check if images have changed first
|
||||||
|
if (graph) {
|
||||||
|
const link = graph.links[linkId];
|
||||||
|
if (link) {
|
||||||
|
const sourceNode = graph.getNodeById(link.origin_id);
|
||||||
|
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
|
||||||
|
// Create current batch identifier (all image sources combined)
|
||||||
|
const currentBatchImageSrcs = sourceNode.imgs.map((img: HTMLImageElement) => 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 as any).graph) {
|
||||||
|
const graph2 = (this.canvas.node as any).graph;
|
||||||
|
const link2 = graph2.links[linkId];
|
||||||
|
if (link2) {
|
||||||
|
const sourceNode = graph2.getNodeById(link2.origin_id);
|
||||||
|
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
|
||||||
|
// The connected node has images in its output - handle multiple images (batch)
|
||||||
|
log.info(`Found ${sourceNode.imgs.length} image(s) in connected node's output, loading all`);
|
||||||
|
|
||||||
|
// Create a combined source identifier for batch detection
|
||||||
|
const batchImageSrcs = sourceNode.imgs.map((img: HTMLImageElement) => 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 as any).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 as any).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 as number) || 0;
|
||||||
|
let height = (maskOutput.height as number) || 0;
|
||||||
|
const shape = maskOutput.shape as number[]; // 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 as any).channels) {
|
||||||
|
channels = (maskOutput as any).channels;
|
||||||
|
} else {
|
||||||
|
const len = (maskOutput.data as any).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: any) => w.name === "fit_on_add") : null;
|
||||||
|
const shouldFit = fitOnAddWidget && fitOnAddWidget.value;
|
||||||
|
|
||||||
|
let finalMaskImg: HTMLImageElement = 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 as any).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 as any).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 as any).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: string | undefined;
|
||||||
|
if (result.data?.input_images_batch && Array.isArray(result.data.input_images_batch)) {
|
||||||
|
backendBatchHash = result.data.input_images_batch.map((i: any) => 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: string | undefined;
|
||||||
|
if (inputData?.input_images_batch && Array.isArray(inputData.input_images_batch)) {
|
||||||
|
backendHashNow = inputData.input_images_batch.map((i: any) => 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: any) => 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: HTMLImageElement = 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 as any).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(): void {
|
||||||
|
// 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(): void {
|
scheduleDataCheck(): void {
|
||||||
if (this.canvas.pendingDataCheck) {
|
if (this.canvas.pendingDataCheck) {
|
||||||
clearTimeout(this.canvas.pendingDataCheck);
|
clearTimeout(this.canvas.pendingDataCheck);
|
||||||
@@ -499,59 +931,11 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
convertTensorToImageData(tensor: any): ImageData | null {
|
convertTensorToImageData(tensor: any): ImageData | null {
|
||||||
try {
|
return tensorToImageData(tensor, 'rgb');
|
||||||
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: ImageData): Promise<HTMLImageElement> {
|
async createImageFromData(imageData: ImageData): Promise<HTMLImageElement> {
|
||||||
return new Promise((resolve, reject) => {
|
return createImageFromImageData(imageData);
|
||||||
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: any): Promise<void> {
|
async processMaskData(maskData: any): Promise<void> {
|
||||||
@@ -618,12 +1002,7 @@ export class CanvasIO {
|
|||||||
const newLayers: (Layer | null)[] = [];
|
const newLayers: (Layer | null)[] = [];
|
||||||
|
|
||||||
for (const imageData of result.images) {
|
for (const imageData of result.images) {
|
||||||
const img = new Image();
|
const img = await createImageFromSource(imageData);
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
img.onload = resolve;
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = imageData;
|
|
||||||
});
|
|
||||||
|
|
||||||
let processedImage = img;
|
let processedImage = img;
|
||||||
|
|
||||||
@@ -652,37 +1031,31 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clipImageToShape(image: HTMLImageElement, shape: Shape): Promise<HTMLImageElement> {
|
async clipImageToShape(image: HTMLImageElement, shape: Shape): Promise<HTMLImageElement> {
|
||||||
return new Promise((resolve, reject) => {
|
const { canvas, ctx } = createCanvas(image.width, image.height);
|
||||||
const { canvas, ctx } = createCanvas(image.width, image.height);
|
if (!ctx) {
|
||||||
if (!ctx) {
|
throw new Error("Could not create canvas context for clipping");
|
||||||
reject(new Error("Could not create canvas context for clipping"));
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw the image first
|
// Draw the image first
|
||||||
ctx.drawImage(image, 0, 0);
|
ctx.drawImage(image, 0, 0);
|
||||||
|
|
||||||
// Calculate custom shape position accounting for extensions
|
// Calculate custom shape position accounting for extensions
|
||||||
// Custom shape should maintain its relative position within the original canvas area
|
// 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 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 shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
||||||
const shapeOffsetY = ext.top; // Add top 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
|
// Create a clipping mask using the shape with extension offset
|
||||||
ctx.globalCompositeOperation = 'destination-in';
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
||||||
for (let i = 1; i < shape.points.length; i++) {
|
for (let i = 1; i < shape.points.length; i++) {
|
||||||
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
||||||
}
|
}
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Create a new image from the clipped canvas
|
// Create a new image from the clipped canvas
|
||||||
const clippedImage = new Image();
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
clippedImage.onload = () => resolve(clippedImage);
|
|
||||||
clippedImage.onerror = () => reject(new Error("Failed to create clipped image"));
|
|
||||||
clippedImage.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -796,8 +796,8 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
// Position above main canvas but below cursor overlay
|
// Position above main canvas but below cursor overlay
|
||||||
this.strokeOverlayCanvas.style.position = 'absolute';
|
this.strokeOverlayCanvas.style.position = 'absolute';
|
||||||
this.strokeOverlayCanvas.style.left = '1px';
|
this.strokeOverlayCanvas.style.left = '0px';
|
||||||
this.strokeOverlayCanvas.style.top = '1px';
|
this.strokeOverlayCanvas.style.top = '0px';
|
||||||
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
||||||
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
||||||
// Opacity is now controlled by MaskTool.previewOpacity
|
// Opacity is now controlled by MaskTool.previewOpacity
|
||||||
|
|||||||
@@ -456,12 +456,13 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
|
|
||||||
if (this.maskUndoStack.length > 0) {
|
if (this.maskUndoStack.length > 0) {
|
||||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
if (maskCtx) {
|
this.canvas.maskTool.restoreMaskFromSavedState(prevState);
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
|
||||||
maskCtx.drawImage(prevState, 0, 0);
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
}
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,12 +475,13 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
const nextState = this.maskRedoStack.pop();
|
const nextState = this.maskRedoStack.pop();
|
||||||
if (nextState) {
|
if (nextState) {
|
||||||
this.maskUndoStack.push(nextState);
|
this.maskUndoStack.push(nextState);
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
if (maskCtx) {
|
this.canvas.maskTool.restoreMaskFromSavedState(nextState);
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
|
||||||
maskCtx.drawImage(nextState, 0, 0);
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
}
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
this.canvas.updateHistoryButtons();
|
this.canvas.updateHistoryButtons();
|
||||||
|
|||||||
@@ -1029,7 +1029,9 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
}
|
}
|
||||||
}, [controlPanel, canvasContainer, layersPanelContainer]) as HTMLDivElement;
|
}, [controlPanel, canvasContainer, layersPanelContainer]) as HTMLDivElement;
|
||||||
|
|
||||||
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
if (node.addDOMWidget) {
|
||||||
|
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||||
|
}
|
||||||
|
|
||||||
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`) as HTMLButtonElement;
|
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`) as HTMLButtonElement;
|
||||||
let backdrop: HTMLDivElement | null = null;
|
let backdrop: HTMLDivElement | null = null;
|
||||||
@@ -1141,7 +1143,12 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
if (!(window as any).canvasExecutionStates) {
|
if (!(window as any).canvasExecutionStates) {
|
||||||
(window as any).canvasExecutionStates = new Map<string, any>();
|
(window as any).canvasExecutionStates = new Map<string, any>();
|
||||||
}
|
}
|
||||||
(node as any).canvasWidget = canvas;
|
|
||||||
|
// Store the entire widget object, not just the canvas
|
||||||
|
(node as any).canvasWidget = {
|
||||||
|
canvas: canvas,
|
||||||
|
panel: controlPanel
|
||||||
|
};
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
canvas.loadInitialState();
|
canvas.loadInitialState();
|
||||||
@@ -1163,7 +1170,7 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
canvas.setPreviewVisibility(value);
|
canvas.setPreviewVisibility(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((node as any).graph && (node as any).graph.canvas) {
|
if ((node as any).graph && (node as any).graph.canvas && node.setDirtyCanvas) {
|
||||||
node.setDirtyCanvas(true, true);
|
node.setDirtyCanvas(true, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1255,10 +1262,156 @@ app.registerExtension({
|
|||||||
const canvasWidget = await createCanvasWidget(this, null, app);
|
const canvasWidget = await createCanvasWidget(this, null, app);
|
||||||
canvasNodeInstances.set(this.id, canvasWidget);
|
canvasNodeInstances.set(this.id, canvasWidget);
|
||||||
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
||||||
|
|
||||||
|
// Store the canvas widget on the node
|
||||||
|
(this as any).canvasWidget = canvasWidget;
|
||||||
|
|
||||||
|
// Check if there are already connected inputs
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.setDirtyCanvas(true, true);
|
if (this.inputs && this.inputs.length > 0) {
|
||||||
}, 100);
|
// Check if input_image (index 0) is connected
|
||||||
|
if (this.inputs[0] && this.inputs[0].link) {
|
||||||
|
log.info("Input image already connected on node creation, checking for data...");
|
||||||
|
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||||
|
canvasWidget.canvas.inputDataLoaded = false;
|
||||||
|
// Only allow images on init; mask should load only on mask connect or execution
|
||||||
|
canvasWidget.canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "init_image_connected" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.setDirtyCanvas) {
|
||||||
|
this.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add onConnectionsChange handler to detect when inputs are connected
|
||||||
|
nodeType.prototype.onConnectionsChange = function (this: ComfyNode, type: number, index: number, connected: boolean, link_info: any) {
|
||||||
|
log.info(`onConnectionsChange called: type=${type}, index=${index}, connected=${connected}`, link_info);
|
||||||
|
|
||||||
|
// Check if this is an input connection (type 1 = INPUT)
|
||||||
|
if (type === 1) {
|
||||||
|
// Get the canvas widget - it might be in different places
|
||||||
|
const canvasWidget = (this as any).canvasWidget;
|
||||||
|
const canvas = canvasWidget?.canvas || canvasWidget;
|
||||||
|
|
||||||
|
if (!canvas || !canvas.canvasIO) {
|
||||||
|
log.warn("Canvas not ready in onConnectionsChange, scheduling retry...");
|
||||||
|
// Retry multiple times with increasing delays
|
||||||
|
const retryDelays = [500, 1000, 2000];
|
||||||
|
let retryCount = 0;
|
||||||
|
|
||||||
|
const tryAgain = () => {
|
||||||
|
const retryCanvas = (this as any).canvasWidget?.canvas || (this as any).canvasWidget;
|
||||||
|
if (retryCanvas && retryCanvas.canvasIO) {
|
||||||
|
log.info("Canvas now ready, checking for input data...");
|
||||||
|
if (connected) {
|
||||||
|
retryCanvas.inputDataLoaded = false;
|
||||||
|
// Respect which input triggered the connection:
|
||||||
|
const opts = (index === 1)
|
||||||
|
? { allowImage: false, allowMask: true, reason: "mask_connect" }
|
||||||
|
: { allowImage: true, allowMask: false, reason: "image_connect" };
|
||||||
|
retryCanvas.canvasIO.checkForInputData(opts);
|
||||||
|
}
|
||||||
|
} else if (retryCount < retryDelays.length) {
|
||||||
|
log.warn(`Canvas still not ready, retry ${retryCount + 1}/${retryDelays.length}...`);
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
} else {
|
||||||
|
log.error("Canvas failed to initialize after multiple retries");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input_image connection (index 0)
|
||||||
|
if (index === 0) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input image connected, marking for data check...");
|
||||||
|
// Reset the input data loaded flag to allow loading the new connection
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
// Also reset the last loaded image source and link ID to allow the new image
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
// Mark that we have a pending input connection
|
||||||
|
canvas.hasPendingInputConnection = true;
|
||||||
|
|
||||||
|
// If mask input is not connected and a mask was auto-applied from input_mask before, clear it now
|
||||||
|
if (!(this.inputs && this.inputs[1] && this.inputs[1].link)) {
|
||||||
|
if ((canvas as any).maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
canvas.maskTool.clear();
|
||||||
|
canvas.render();
|
||||||
|
(canvas as any).maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask because input_image connected without input_mask");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after connection...");
|
||||||
|
// Only load images here; masks should not auto-load on image connect
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "image_connect" });
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
log.info("Input image disconnected");
|
||||||
|
canvas.hasPendingInputConnection = false;
|
||||||
|
// Reset when disconnected so a new connection can load
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input_mask connection (index 1)
|
||||||
|
if (index === 1) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input mask connected");
|
||||||
|
|
||||||
|
// DON'T clear existing mask when connecting a new input
|
||||||
|
// Reset the loaded mask link ID to allow loading from the new connection
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
|
||||||
|
// Mark that we have a pending mask connection
|
||||||
|
canvas.hasPendingMaskConnection = true;
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after mask connection...");
|
||||||
|
// Only load mask here if it's immediately available from the connected node
|
||||||
|
// Don't load stale masks from backend storage
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: false, allowMask: true, reason: "mask_connect" });
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
log.info("Input mask disconnected");
|
||||||
|
canvas.hasPendingMaskConnection = false;
|
||||||
|
// If the current mask came from input_mask, clear it to avoid affecting images when mask is not connected
|
||||||
|
if ((canvas as any).maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
(canvas as any).maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask due to mask input disconnection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add onExecuted handler to check for input data after workflow execution
|
||||||
|
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||||
|
nodeType.prototype.onExecuted = function (this: ComfyNode, message: any) {
|
||||||
|
log.info("Node executed, checking for input data...");
|
||||||
|
|
||||||
|
const canvas = (this as any).canvasWidget?.canvas || (this as any).canvasWidget;
|
||||||
|
if (canvas && canvas.canvasIO) {
|
||||||
|
// Don't reset inputDataLoaded - just check for new data
|
||||||
|
// On execution we allow both image and mask to load
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: "execution" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call original if it exists
|
||||||
|
if (originalOnExecuted) {
|
||||||
|
originalOnExecuted.apply(this, arguments as any);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemoved = nodeType.prototype.onRemoved;
|
const onRemoved = nodeType.prototype.onRemoved;
|
||||||
|
|||||||
@@ -507,7 +507,6 @@ export class MaskEditorIntegration {
|
|||||||
maskSize: {width: bounds.width, height: bounds.height}
|
maskSize: {width: bounds.width, height: bounds.height}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use the chunk system instead of direct canvas manipulation
|
|
||||||
this.maskTool.setMask(maskAsImage);
|
this.maskTool.setMask(maskAsImage);
|
||||||
|
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
|
|||||||
@@ -1674,6 +1674,27 @@ export class MaskTool {
|
|||||||
log.info("Cleared all mask data from all chunks");
|
log.info("Cleared all mask data from all chunks");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all chunks and restores mask from saved state
|
||||||
|
* This is used during undo/redo operations to ensure clean state restoration
|
||||||
|
*/
|
||||||
|
restoreMaskFromSavedState(savedMaskCanvas: HTMLCanvasElement): void {
|
||||||
|
// First, clear ALL chunks to ensure no leftover data
|
||||||
|
this.clearAllMaskChunks();
|
||||||
|
|
||||||
|
// Now apply the saved mask state to chunks
|
||||||
|
if (savedMaskCanvas.width > 0 && savedMaskCanvas.height > 0) {
|
||||||
|
// Apply the saved mask to the chunk system at the correct position
|
||||||
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
|
this.applyMaskCanvasToChunks(savedMaskCanvas, this.x, this.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the active mask canvas to show the restored state
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
|
||||||
|
log.debug("Restored mask from saved state with clean chunk system");
|
||||||
|
}
|
||||||
|
|
||||||
getMask(): HTMLCanvasElement {
|
getMask(): HTMLCanvasElement {
|
||||||
// Return the current active mask canvas which shows all chunks
|
// Return the current active mask canvas which shows all chunks
|
||||||
// Only update if there are pending changes to avoid unnecessary redraws
|
// Only update if there are pending changes to avoid unnecessary redraws
|
||||||
@@ -1793,15 +1814,47 @@ export class MaskTool {
|
|||||||
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMask(image: HTMLImageElement): void {
|
setMask(image: HTMLImageElement, isFromInputMask: boolean = false): void {
|
||||||
// Clear existing mask chunks in the output area first
|
|
||||||
const bounds = this.canvasInstance.outputAreaBounds;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
this.clearMaskInArea(bounds.x, bounds.y, image.width, image.height);
|
|
||||||
|
|
||||||
// Add the new mask using the chunk system
|
if (isFromInputMask) {
|
||||||
this.addMask(image);
|
// For INPUT MASK - process black background to transparent using luminance
|
||||||
|
// Center like input images
|
||||||
log.info(`MaskTool set new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
const centerX = bounds.x + (bounds.width - image.width) / 2;
|
||||||
|
const centerY = bounds.y + (bounds.height - image.height) / 2;
|
||||||
|
|
||||||
|
// Prepare mask where alpha = luminance (white = applied, black = transparent)
|
||||||
|
const { canvas: maskCanvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error("Could not create mask processing context");
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
|
||||||
|
const imgData = ctx.getImageData(0, 0, image.width, image.height);
|
||||||
|
const data = imgData.data;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||||
|
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||||
|
data[i] = 255; // force white color (color channels ignored downstream)
|
||||||
|
data[i + 1] = 255;
|
||||||
|
data[i + 2] = 255;
|
||||||
|
data[i + 3] = lum; // alpha encodes mask strength: white -> strong, black -> 0
|
||||||
|
}
|
||||||
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
|
||||||
|
// Clear target area and apply to chunked system at centered position
|
||||||
|
this.clearMaskInArea(centerX, centerY, image.width, image.height);
|
||||||
|
this.applyMaskCanvasToChunks(maskCanvas, centerX, centerY);
|
||||||
|
|
||||||
|
// Refresh state and UI
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
this.canvasInstance.canvasState.saveMaskState();
|
||||||
|
this.canvasInstance.render();
|
||||||
|
log.info(`MaskTool set INPUT MASK at centered position (${centerX}, ${centerY}) using luminance as alpha`);
|
||||||
|
} else {
|
||||||
|
// For SAM Detector and other sources - just clear and add without processing
|
||||||
|
this.clearMaskInArea(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||||
|
this.addMask(image);
|
||||||
|
log.info(`MaskTool set mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -282,36 +282,61 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
|
|||||||
log.debug("Attempting to reload SAM result image");
|
log.debug("Attempting to reload SAM result image");
|
||||||
const originalSrc = resultImage.src;
|
const originalSrc = resultImage.src;
|
||||||
|
|
||||||
// Add cache-busting parameter to force fresh load
|
// Check if it's a data URL (base64) - don't add parameters to data URLs
|
||||||
const url = new URL(originalSrc);
|
if (originalSrc.startsWith('data:')) {
|
||||||
url.searchParams.set('_t', Date.now().toString());
|
log.debug("Image is a data URL, skipping reload with parameters");
|
||||||
|
// For data URLs, just ensure the image is loaded
|
||||||
await new Promise((resolve, reject) => {
|
if (!resultImage.complete || resultImage.naturalWidth === 0) {
|
||||||
const img = new Image();
|
await new Promise((resolve, reject) => {
|
||||||
img.crossOrigin = "anonymous";
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
// Copy the loaded image data to the original image
|
resultImage.width = img.width;
|
||||||
resultImage.src = img.src;
|
resultImage.height = img.height;
|
||||||
resultImage.width = img.width;
|
log.debug("Data URL image loaded successfully", {
|
||||||
resultImage.height = img.height;
|
width: img.width,
|
||||||
log.debug("SAM result image reloaded successfully", {
|
height: img.height
|
||||||
width: img.width,
|
});
|
||||||
height: img.height,
|
resolve(img);
|
||||||
originalSrc: originalSrc,
|
};
|
||||||
newSrc: img.src
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to load data URL image", error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = originalSrc; // Use original src without modifications
|
||||||
});
|
});
|
||||||
resolve(img);
|
}
|
||||||
};
|
} else {
|
||||||
img.onerror = (error) => {
|
// For regular URLs, add cache-busting parameter
|
||||||
log.error("Failed to reload SAM result image", {
|
const url = new URL(originalSrc);
|
||||||
originalSrc: originalSrc,
|
url.searchParams.set('_t', Date.now().toString());
|
||||||
newSrc: url.toString(),
|
|
||||||
error: error
|
await new Promise((resolve, reject) => {
|
||||||
});
|
const img = new Image();
|
||||||
reject(error);
|
img.crossOrigin = "anonymous";
|
||||||
};
|
img.onload = () => {
|
||||||
img.src = url.toString();
|
// Copy the loaded image data to the original image
|
||||||
});
|
resultImage.src = img.src;
|
||||||
|
resultImage.width = img.width;
|
||||||
|
resultImage.height = img.height;
|
||||||
|
log.debug("SAM result image reloaded successfully", {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: img.src
|
||||||
|
});
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to reload SAM result image", {
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: url.toString(),
|
||||||
|
error: error
|
||||||
|
});
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = url.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to load image from SAM Detector.", error);
|
log.error("Failed to load image from SAM Detector.", error);
|
||||||
@@ -333,32 +358,43 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
|
|||||||
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||||
log.debug("Checking canvas and maskTool availability", {
|
log.debug("Checking canvas and maskTool availability", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasCanvasProperty: !!canvas.canvas,
|
||||||
|
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
|
||||||
hasMaskTool: !!canvas.maskTool,
|
hasMaskTool: !!canvas.maskTool,
|
||||||
|
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
|
||||||
maskToolType: typeof canvas.maskTool,
|
maskToolType: typeof canvas.maskTool,
|
||||||
|
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas)
|
canvasKeys: Object.keys(canvas)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!canvas.maskTool) {
|
// Get the actual Canvas object and its maskTool
|
||||||
|
const actualCanvas = canvas.canvas || canvas;
|
||||||
|
const maskTool = actualCanvas.maskTool;
|
||||||
|
|
||||||
|
if (!maskTool) {
|
||||||
log.error("MaskTool is not available. Canvas state:", {
|
log.error("MaskTool is not available. Canvas state:", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasActualCanvas: !!actualCanvas,
|
||||||
canvasConstructor: canvas.constructor.name,
|
canvasConstructor: canvas.constructor.name,
|
||||||
|
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas),
|
canvasKeys: Object.keys(canvas),
|
||||||
maskToolValue: canvas.maskTool
|
actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
|
||||||
|
maskToolValue: maskTool
|
||||||
});
|
});
|
||||||
throw new Error("Mask tool not available or not initialized");
|
throw new Error("Mask tool not available or not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug("Applying SAM mask to canvas using addMask method");
|
log.debug("Applying SAM mask to canvas using setMask method");
|
||||||
|
|
||||||
// Use the addMask method which overlays on existing mask without clearing it
|
// Use the setMask method which clears existing mask and sets new one
|
||||||
canvas.maskTool.addMask(maskAsImage);
|
maskTool.setMask(maskAsImage);
|
||||||
|
|
||||||
// Update canvas and save state (same as MaskEditorIntegration)
|
// Update canvas and save state (same as MaskEditorIntegration)
|
||||||
canvas.render();
|
actualCanvas.render();
|
||||||
canvas.saveState();
|
actualCanvas.saveState();
|
||||||
|
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
await updateNodePreview(canvas, node, true);
|
await updateNodePreview(actualCanvas, node, true);
|
||||||
|
|
||||||
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
||||||
|
|
||||||
@@ -399,15 +435,23 @@ export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
|||||||
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||||
|
|
||||||
// Automatically send canvas to clipspace and start monitoring
|
// Automatically send canvas to clipspace and start monitoring
|
||||||
if ((node as any).canvasWidget && (node as any).canvasWidget.canvas) {
|
if ((node as any).canvasWidget) {
|
||||||
const canvas = (node as any).canvasWidget; // canvasWidget IS the Canvas object
|
const canvasWidget = (node as any).canvasWidget;
|
||||||
|
const canvas = canvasWidget.canvas || canvasWidget; // Get actual Canvas object
|
||||||
|
|
||||||
// Use ImageUploadUtils to upload canvas
|
// Use ImageUploadUtils to upload canvas and get server URL (Impact Pack compatibility)
|
||||||
const uploadResult = await uploadCanvasAsImage(canvas, {
|
const uploadResult = await uploadCanvasAsImage(canvas, {
|
||||||
filenamePrefix: 'layerforge-sam',
|
filenamePrefix: 'layerforge-sam',
|
||||||
nodeId: node.id
|
nodeId: node.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.debug("Uploaded canvas for SAM Detector", {
|
||||||
|
filename: uploadResult.filename,
|
||||||
|
imageUrl: uploadResult.imageUrl,
|
||||||
|
width: uploadResult.imageElement.width,
|
||||||
|
height: uploadResult.imageElement.height
|
||||||
|
});
|
||||||
|
|
||||||
// Set the image to the node for clipspace
|
// Set the image to the node for clipspace
|
||||||
node.imgs = [uploadResult.imageElement];
|
node.imgs = [uploadResult.imageElement];
|
||||||
(node as any).clipspaceImg = uploadResult.imageElement;
|
(node as any).clipspaceImg = uploadResult.imageElement;
|
||||||
|
|||||||
29
src/types.ts
29
src/types.ts
@@ -1,6 +1,14 @@
|
|||||||
import type { Canvas as CanvasClass } from './Canvas';
|
import type { Canvas as CanvasClass } from './Canvas';
|
||||||
import type { CanvasLayers } from './CanvasLayers';
|
import type { CanvasLayers } from './CanvasLayers';
|
||||||
|
|
||||||
|
export interface ComfyWidget {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
value: any;
|
||||||
|
callback?: (value: any) => void;
|
||||||
|
options?: any;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Layer {
|
export interface Layer {
|
||||||
id: string;
|
id: string;
|
||||||
image: HTMLImageElement;
|
image: HTMLImageElement;
|
||||||
@@ -32,15 +40,16 @@ export interface Layer {
|
|||||||
|
|
||||||
export interface ComfyNode {
|
export interface ComfyNode {
|
||||||
id: number;
|
id: number;
|
||||||
|
type: string;
|
||||||
|
widgets: ComfyWidget[];
|
||||||
imgs?: HTMLImageElement[];
|
imgs?: HTMLImageElement[];
|
||||||
widgets: any[];
|
size?: [number, number];
|
||||||
size: [number, number];
|
|
||||||
graph: any;
|
|
||||||
canvasWidget?: any;
|
|
||||||
onResize?: () => void;
|
onResize?: () => void;
|
||||||
addDOMWidget: (name: string, type: string, element: HTMLElement, options?: any) => any;
|
setDirtyCanvas?: (dirty: boolean, propagate: boolean) => void;
|
||||||
addWidget: (type: string, name: string, value: any, callback?: (value: any) => void, options?: any) => any;
|
graph?: any;
|
||||||
setDirtyCanvas: (force: boolean, dirty: boolean) => void;
|
onRemoved?: () => void;
|
||||||
|
addDOMWidget?: (name: string, type: string, element: HTMLElement) => void;
|
||||||
|
inputs?: Array<{ link: any }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -79,8 +88,14 @@ export interface Canvas {
|
|||||||
imageCache: any;
|
imageCache: any;
|
||||||
dataInitialized: boolean;
|
dataInitialized: boolean;
|
||||||
pendingDataCheck: number | null;
|
pendingDataCheck: number | null;
|
||||||
|
pendingInputDataCheck: number | null;
|
||||||
pendingBatchContext: any;
|
pendingBatchContext: any;
|
||||||
canvasLayers: any;
|
canvasLayers: any;
|
||||||
|
inputDataLoaded: boolean;
|
||||||
|
lastLoadedLinkId: any;
|
||||||
|
lastLoadedMaskLinkId: any;
|
||||||
|
lastLoadedImageSrc?: string;
|
||||||
|
outputAreaBounds: OutputAreaBounds;
|
||||||
saveState: () => void;
|
saveState: () => void;
|
||||||
render: () => void;
|
render: () => void;
|
||||||
updateSelection: (layers: Layer[]) => void;
|
updateSelection: (layers: Layer[]) => void;
|
||||||
|
|||||||
@@ -386,3 +386,111 @@ export function canvasToMaskImage(canvas: HTMLCanvasElement): Promise<HTMLImageE
|
|||||||
img.src = canvas.toDataURL();
|
img.src = canvas.toDataURL();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scales an image to fit within specified bounds while maintaining aspect ratio
|
||||||
|
* @param image - Image to scale
|
||||||
|
* @param targetWidth - Target width to fit within
|
||||||
|
* @param targetHeight - Target height to fit within
|
||||||
|
* @returns Promise with scaled Image element
|
||||||
|
*/
|
||||||
|
export async function scaleImageToFit(image: HTMLImageElement, targetWidth: number, targetHeight: number): Promise<HTMLImageElement> {
|
||||||
|
const scale = Math.min(targetWidth / image.width, targetHeight / image.height);
|
||||||
|
const scaledWidth = Math.max(1, Math.round(image.width * scale));
|
||||||
|
const scaledHeight = Math.max(1, Math.round(image.height * scale));
|
||||||
|
|
||||||
|
const { canvas, ctx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error("Could not create scaled image context");
|
||||||
|
|
||||||
|
ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const scaledImg = new Image();
|
||||||
|
scaledImg.onload = () => resolve(scaledImg);
|
||||||
|
scaledImg.onerror = reject;
|
||||||
|
scaledImg.src = canvas.toDataURL();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified tensor to image data conversion
|
||||||
|
* Handles both RGB images and grayscale masks
|
||||||
|
* @param tensor - Input tensor data
|
||||||
|
* @param mode - 'rgb' for images or 'grayscale' for masks
|
||||||
|
* @returns ImageData object
|
||||||
|
*/
|
||||||
|
export function tensorToImageData(tensor: any, mode: 'rgb' | 'grayscale' = 'rgb'): ImageData | null {
|
||||||
|
try {
|
||||||
|
const shape = tensor.shape;
|
||||||
|
const height = shape[1];
|
||||||
|
const width = shape[2];
|
||||||
|
const channels = shape[3] || 1; // Default to 1 for masks
|
||||||
|
|
||||||
|
log.debug("Converting tensor:", { shape, channels, mode });
|
||||||
|
|
||||||
|
const imageData = new ImageData(width, height);
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
|
|
||||||
|
const flatData = tensor.data;
|
||||||
|
const pixelCount = width * height;
|
||||||
|
|
||||||
|
const min = tensor.min_val ?? 0;
|
||||||
|
const max = tensor.max_val ?? 1;
|
||||||
|
const denom = (max - min) || 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < pixelCount; i++) {
|
||||||
|
const pixelIndex = i * 4;
|
||||||
|
const tensorIndex = i * channels;
|
||||||
|
|
||||||
|
let lum: number;
|
||||||
|
if (mode === 'grayscale' || channels === 1) {
|
||||||
|
lum = flatData[tensorIndex];
|
||||||
|
} else {
|
||||||
|
// Compute luminance for RGB
|
||||||
|
const r = flatData[tensorIndex + 0] ?? 0;
|
||||||
|
const g = flatData[tensorIndex + 1] ?? 0;
|
||||||
|
const b = flatData[tensorIndex + 2] ?? 0;
|
||||||
|
lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
let norm = (lum - min) / denom;
|
||||||
|
if (!isFinite(norm)) norm = 0;
|
||||||
|
norm = Math.max(0, Math.min(1, norm));
|
||||||
|
const value = Math.round(norm * 255);
|
||||||
|
|
||||||
|
if (mode === 'grayscale') {
|
||||||
|
// For masks: RGB = value, A = 255 (MaskTool reads luminance)
|
||||||
|
data[pixelIndex] = value;
|
||||||
|
data[pixelIndex + 1] = value;
|
||||||
|
data[pixelIndex + 2] = value;
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
} else {
|
||||||
|
// For images: RGB from channels, A = 255
|
||||||
|
for (let c = 0; c < Math.min(3, channels); c++) {
|
||||||
|
const channelValue = flatData[tensorIndex + c];
|
||||||
|
const channelNorm = (channelValue - min) / denom;
|
||||||
|
data[pixelIndex + c] = Math.round(channelNorm * 255);
|
||||||
|
}
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imageData.data.set(data);
|
||||||
|
return imageData;
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Error converting tensor:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an HTMLImageElement from ImageData
|
||||||
|
* @param imageData - Input ImageData
|
||||||
|
* @returns Promise with HTMLImageElement
|
||||||
|
*/
|
||||||
|
export async function createImageFromImageData(imageData: ImageData): Promise<HTMLImageElement> {
|
||||||
|
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);
|
||||||
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user