8 Commits

Author SHA1 Message Date
Dariusz L
d0e6bf8b3d Update pyproject.toml 2025-08-09 03:24:49 +02:00
Dariusz L
da37900b33 Refactor: unify image handling in CanvasIO via helpers
Removed duplicate code from CanvasIO.ts and replaced it with unified helpers from ImageUtils.ts. All tensor-to-image conversions and image creation now use centralized utility functions for consistency and maintainability.
2025-08-09 03:07:18 +02:00
Dariusz L
64c5e49707 Unify mask scaling logic with scaleImageToFit util
Refactored mask scaling and drawing into the scaleImageToFit method in ImageUtils.ts. Updated CanvasIO.ts to use this utility, reducing code duplication and improving maintainability.
2025-08-09 02:43:36 +02:00
Dariusz L
06d94f6a63 Improve mask loading logic on node connection
Updated mask loading to immediately use available data from connected nodes and preserve existing masks if none is provided. Backend mask data is only fetched after workflow execution, ensuring no stale data is loaded during connection.
2025-08-09 02:33:28 +02:00
Dariusz L
b21d6e3502 implement strict image/mask input separation
Enhanced LayerForge input handling to strictly separate image and mask loading based on connection type. Images now only load when allowImage=true and masks only when allowMask=true, preventing unintended cross-loading between input types.
2025-08-09 01:44:31 +02:00
Dariusz L
285ad035b2 Improve batch images and mask handling
Fixed batch image processing to prevent duplicates and layer deletion while ensuring proper mask loading from input_mask. Images are now added as new layers without removing existing ones, and masks are always checked from backend regardless of image state.
2025-08-09 00:49:58 +02:00
Dariusz L
949ffa0143 Repair Undo/Redo in Masking Mode 2025-08-08 22:41:19 +02:00
Dariusz L
afdac52144 Added mask and image input 2025-08-08 22:23:15 +02:00
21 changed files with 1928 additions and 356 deletions

View File

@@ -179,6 +179,10 @@ class LayerForgeNode:
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
"node_id": ("STRING", {"default": "0"}),
},
"optional": {
"input_image": ("IMAGE",),
"input_mask": ("MASK",),
},
"hidden": {
"prompt": ("PROMPT",),
"unique_id": ("UNIQUE_ID",),
@@ -239,7 +243,7 @@ class LayerForgeNode:
_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:
@@ -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})")
# 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
processed_image = None
@@ -433,6 +512,63 @@ class LayerForgeNode:
log_info("WebSocket connection closed")
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}")
async def get_canvas_data(request):
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_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
raise

View File

@@ -73,6 +73,8 @@ export class Canvas {
this.canvasContainer = null;
this.dataInitialized = false;
this.pendingDataCheck = null;
this.pendingInputDataCheck = null;
this.inputDataLoaded = false;
this.imageCache = new Map();
this.requestSaveState = () => { };
this.outputAreaShape = null;
@@ -372,6 +374,10 @@ export class Canvas {
return widget ? widget.value : false;
};
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()) {
lastExecutionStartTime = Date.now();
// Store a snapshot of the context for the upcoming batch
@@ -394,6 +400,9 @@ export class Canvas {
}
};
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()) {
log.info('Auto-refresh triggered, importing latest images.');
if (!this.pendingBatchContext) {

View File

@@ -2,6 +2,7 @@ import { createCanvas } from "./utils/CommonUtils.js";
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { showErrorNotification } from "./utils/NotificationUtils.js";
import { webSocketManager } from "./utils/WebSocketManager.js";
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
const log = createModuleLogger('CanvasIO');
export class CanvasIO {
constructor(canvas) {
@@ -247,17 +248,12 @@ export class CanvasIO {
async addInputToCanvas(inputImage, inputMask) {
try {
log.debug("Adding input to canvas:", { inputImage });
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
if (!tempCtx)
throw new Error("Could not create temp context");
const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), inputImage.width, inputImage.height);
tempCtx.putImageData(imgData, 0, 0);
const image = new Image();
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
image.src = tempCanvas.toDataURL();
});
// Use unified tensorToImageData for RGB image
const imageData = tensorToImageData(inputImage, 'rgb');
if (!imageData)
throw new Error("Failed to convert input image tensor");
// Create HTMLImageElement from ImageData
const image = await createImageFromImageData(imageData);
const bounds = this.canvas.outputAreaBounds;
const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.height / inputImage.height * 0.8);
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
@@ -283,17 +279,10 @@ export class CanvasIO {
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
throw new Error("Invalid tensor data");
}
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create canvas context");
const imageData = new ImageData(new Uint8ClampedArray(tensor.data), tensor.width, tensor.height);
ctx.putImageData(imageData, 0, 0);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
img.src = canvas.toDataURL();
});
const imageData = tensorToImageData(tensor, 'rgb');
if (!imageData)
throw new Error("Failed to convert tensor to image data");
return await createImageFromImageData(imageData);
}
catch (error) {
log.error("Error converting tensor to image:", error);
@@ -314,12 +303,26 @@ export class CanvasIO {
async initNodeData() {
try {
log.info("Starting node data initialization...");
// First check for input data from the backend (new feature)
await this.checkForInputData();
// If we've already loaded input data, don't continue with old initialization
if (this.canvas.inputDataLoaded) {
log.debug("Input data already loaded, skipping old initialization");
this.canvas.dataInitialized = true;
return;
}
if (!this.canvas.node || !this.canvas.node.inputs) {
log.debug("Node or inputs not ready");
return this.scheduleDataCheck();
}
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const imageLinkId = this.canvas.node.inputs[0].link;
// Check if we already loaded this link
if (this.canvas.lastLoadedLinkId === imageLinkId) {
log.debug(`Link ${imageLinkId} already loaded via new system, marking as initialized`);
this.canvas.dataInitialized = true;
return;
}
const imageData = window.app.nodeOutputs[imageLinkId];
if (imageData) {
log.debug("Found image data:", imageData);
@@ -331,6 +334,10 @@ export class CanvasIO {
return this.scheduleDataCheck();
}
}
else {
// No input connected, mark as initialized to stop repeated checks
this.canvas.dataInitialized = true;
}
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
const maskLinkId = this.canvas.node.inputs[1].link;
const maskData = window.app.nodeOutputs[maskLinkId];
@@ -345,6 +352,390 @@ export class CanvasIO {
return this.scheduleDataCheck();
}
}
async checkForInputData(options) {
try {
const nodeId = this.canvas.node.id;
const allowImage = options?.allowImage ?? true;
const allowMask = options?.allowMask ?? true;
const reason = options?.reason ?? 'unspecified';
log.info(`Checking for input data for node ${nodeId}... opts: image=${allowImage}, mask=${allowMask}, reason=${reason}`);
// Track loaded links separately for image and mask
let imageLoaded = false;
let maskLoaded = false;
let imageChanged = false;
// First, try to get data from connected node's output if available (IMAGES)
if (allowImage && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const linkId = this.canvas.node.inputs[0].link;
const graph = this.canvas.node.graph;
// Always check if images have changed first
if (graph) {
const link = graph.links[linkId];
if (link) {
const sourceNode = graph.getNodeById(link.origin_id);
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
// Create current batch identifier (all image sources combined)
const currentBatchImageSrcs = sourceNode.imgs.map((img) => img.src).join('|');
// Check if this is the same link we loaded before
if (this.canvas.lastLoadedLinkId === linkId) {
// Same link, check if images actually changed
if (this.canvas.lastLoadedImageSrc !== currentBatchImageSrcs) {
log.info(`Batch images changed for link ${linkId} (${sourceNode.imgs.length} images), will reload...`);
log.debug(`Previous batch hash: ${this.canvas.lastLoadedImageSrc?.substring(0, 100)}...`);
log.debug(`Current batch hash: ${currentBatchImageSrcs.substring(0, 100)}...`);
imageChanged = true;
// Clear the inputDataLoaded flag to force reload from backend
this.canvas.inputDataLoaded = false;
// Clear the lastLoadedImageSrc to force reload
this.canvas.lastLoadedImageSrc = undefined;
// Clear backend data to force fresh load
fetch(`/layerforge/clear_input_data/${nodeId}`, { method: 'POST' })
.then(() => log.debug("Backend input data cleared due to image change"))
.catch(err => log.error("Failed to clear backend data:", err));
}
else {
log.debug(`Batch images for link ${linkId} unchanged (${sourceNode.imgs.length} images)`);
imageLoaded = true;
}
}
else {
// Different link or first load
log.info(`New link ${linkId} detected, will load ${sourceNode.imgs.length} images`);
imageChanged = false; // It's not a change, it's a new link
imageLoaded = false; // Need to load
// Reset the inputDataLoaded flag for new link
this.canvas.inputDataLoaded = false;
}
}
}
}
if (!imageLoaded || imageChanged) {
// Reset the inputDataLoaded flag when images change
if (imageChanged) {
this.canvas.inputDataLoaded = false;
log.info("Resetting inputDataLoaded flag due to image change");
}
if (this.canvas.node.graph) {
const graph2 = this.canvas.node.graph;
const link2 = graph2.links[linkId];
if (link2) {
const sourceNode = graph2.getNodeById(link2.origin_id);
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
// The connected node has images in its output - handle multiple images (batch)
log.info(`Found ${sourceNode.imgs.length} image(s) in connected node's output, loading all`);
// Create a combined source identifier for batch detection
const batchImageSrcs = sourceNode.imgs.map((img) => img.src).join('|');
// Mark this link and batch sources as loaded
this.canvas.lastLoadedLinkId = linkId;
this.canvas.lastLoadedImageSrc = batchImageSrcs;
// Don't clear layers - just add new ones
if (imageChanged) {
log.info("Image change detected, will add new layers");
}
// Determine add mode
const fitOnAddWidget = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
// Add all images from the batch as separate layers
for (let i = 0; i < sourceNode.imgs.length; i++) {
const img = sourceNode.imgs[i];
await this.canvas.canvasLayers.addLayerWithImage(img, { name: `Batch Image ${i + 1}` }, // Give each layer a unique name
addMode, this.canvas.outputAreaBounds);
log.debug(`Added batch image ${i + 1}/${sourceNode.imgs.length} to canvas`);
}
this.canvas.inputDataLoaded = true;
imageLoaded = true;
log.info(`All ${sourceNode.imgs.length} input images from batch added as separate layers`);
this.canvas.render();
this.canvas.saveState();
}
}
}
}
}
// Check for mask input separately (from nodeOutputs) ONLY when allowed
if (allowMask && this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
const maskLinkId = this.canvas.node.inputs[1].link;
// Check if we already loaded this mask link
if (this.canvas.lastLoadedMaskLinkId === maskLinkId) {
log.debug(`Mask link ${maskLinkId} already loaded`);
maskLoaded = true;
}
else {
// Try to get mask tensor from nodeOutputs using origin_id (not link id)
const graph = this.canvas.node.graph;
let maskOutput = null;
if (graph) {
const link = graph.links[maskLinkId];
if (link && link.origin_id) {
// Use origin_id to get the actual node output
const nodeOutput = window.app?.nodeOutputs?.[link.origin_id];
log.debug(`Looking for mask output from origin node ${link.origin_id}, found:`, !!nodeOutput);
if (nodeOutput) {
log.debug(`Node ${link.origin_id} output structure:`, {
hasData: !!nodeOutput.data,
hasShape: !!nodeOutput.shape,
dataType: typeof nodeOutput.data,
shapeType: typeof nodeOutput.shape,
keys: Object.keys(nodeOutput)
});
// Only use if it has actual tensor data
if (nodeOutput.data && nodeOutput.shape) {
maskOutput = nodeOutput;
}
}
}
}
if (maskOutput && maskOutput.data && maskOutput.shape) {
try {
// Derive dimensions from shape or explicit width/height
let width = maskOutput.width || 0;
let height = maskOutput.height || 0;
const shape = maskOutput.shape; // e.g. [1,H,W] or [1,H,W,1]
if ((!width || !height) && Array.isArray(shape)) {
if (shape.length >= 3) {
height = shape[1];
width = shape[2];
}
else if (shape.length === 2) {
height = shape[0];
width = shape[1];
}
}
if (!width || !height) {
throw new Error("Cannot determine mask dimensions from nodeOutputs");
}
// Determine channels count
let channels = 1;
if (Array.isArray(shape) && shape.length >= 4) {
channels = shape[3];
}
else if (maskOutput.channels) {
channels = maskOutput.channels;
}
else {
const len = maskOutput.data.length;
channels = Math.max(1, Math.floor(len / (width * height)));
}
// Use unified tensorToImageData for masks
const maskImageData = tensorToImageData(maskOutput, 'grayscale');
if (!maskImageData)
throw new Error("Failed to convert mask tensor to image data");
// Create canvas and put image data
const { canvas: maskCanvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create mask context");
ctx.putImageData(maskImageData, 0, 0);
// Convert to HTMLImageElement
const maskImg = await createImageFromSource(maskCanvas.toDataURL());
// Respect fit_on_add (scale to output area)
const widgets = this.canvas.node.widgets;
const fitOnAddWidget = widgets ? widgets.find((w) => w.name === "fit_on_add") : null;
const shouldFit = fitOnAddWidget && fitOnAddWidget.value;
let finalMaskImg = maskImg;
if (shouldFit) {
const bounds = this.canvas.outputAreaBounds;
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
}
// Apply to MaskTool (centers internally)
if (this.canvas.maskTool) {
this.canvas.maskTool.setMask(finalMaskImg, true);
this.canvas.maskAppliedFromInput = true;
this.canvas.canvasState.saveMaskState();
this.canvas.render();
// Mark this mask link as loaded to avoid re-applying
this.canvas.lastLoadedMaskLinkId = maskLinkId;
maskLoaded = true;
log.info("Applied input mask from nodeOutputs immediately on connection" + (shouldFit ? " (fitted to output area)" : ""));
}
}
catch (err) {
log.warn("Failed to apply mask from nodeOutputs immediately; will wait for backend input_mask after execution", err);
}
}
else {
// nodeOutputs exist but don't have tensor data yet (need workflow execution)
log.info(`Mask node ${this.canvas.node.graph?.links[maskLinkId]?.origin_id} found but has no tensor data yet. Mask will be applied automatically after workflow execution.`);
// Don't retry - data won't be available until workflow runs
}
}
}
// Only check backend if we have actual inputs connected
const hasImageInput = this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link;
const hasMaskInput = this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link;
// If mask input is disconnected, clear any currently applied mask to ensure full separation
if (!hasMaskInput) {
this.canvas.maskAppliedFromInput = false;
this.canvas.lastLoadedMaskLinkId = undefined;
log.info("Mask input disconnected - cleared mask to enforce separation from input_image");
}
if (!hasImageInput && !hasMaskInput) {
log.debug("No inputs connected, skipping backend check");
this.canvas.inputDataLoaded = true;
return;
}
// Skip backend check during mask connection if we didn't get immediate data
if (reason === "mask_connect" && !maskLoaded) {
log.info("No immediate mask data available during connection, skipping backend check to avoid stale data. Will check after execution.");
return;
}
// Check backend for input data only if we have connected inputs
const response = await fetch(`/layerforge/get_input_data/${nodeId}`);
const result = await response.json();
if (result.success && result.has_input) {
// Dedupe: skip only if backend payload matches last loaded batch hash
let backendBatchHash;
if (result.data?.input_images_batch && Array.isArray(result.data.input_images_batch)) {
backendBatchHash = result.data.input_images_batch.map((i) => i.data).join('|');
}
else if (result.data?.input_image) {
backendBatchHash = result.data.input_image;
}
// Check mask separately - don't skip if only images are unchanged AND mask is actually connected AND allowed
const shouldCheckMask = hasMaskInput && allowMask;
if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && !shouldCheckMask) {
log.debug("Backend input data unchanged and no mask to check, skipping reload");
this.canvas.inputDataLoaded = true;
return;
}
else if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && shouldCheckMask) {
log.debug("Images unchanged but need to check mask, continuing...");
imageLoaded = true; // Mark images as already loaded to skip reloading them
}
// Check if we already loaded image data (by checking the current link)
if (allowImage && !imageLoaded && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const currentLinkId = this.canvas.node.inputs[0].link;
if (this.canvas.lastLoadedLinkId !== currentLinkId) {
// Mark this link as loaded
this.canvas.lastLoadedLinkId = currentLinkId;
imageLoaded = false; // Will load from backend
}
}
// Check for mask data from backend ONLY when mask input is actually connected AND allowed
// Only reset if the mask link actually changed
if (allowMask && hasMaskInput && this.canvas.node.inputs && this.canvas.node.inputs[1]) {
const currentMaskLinkId = this.canvas.node.inputs[1].link;
// Only reset if this is a different mask link than what we loaded before
if (this.canvas.lastLoadedMaskLinkId !== currentMaskLinkId) {
maskLoaded = false;
log.debug(`New mask input detected (${currentMaskLinkId}), will check backend for mask data`);
}
else {
log.debug(`Same mask input (${currentMaskLinkId}), mask already loaded`);
maskLoaded = true;
}
}
else {
// No mask input connected, or mask loading not allowed right now
maskLoaded = true; // Mark as loaded to skip mask processing
if (!allowMask) {
log.debug("Mask loading is currently disabled by caller, skipping mask check");
}
else {
log.debug("No mask input connected, skipping mask check");
}
}
log.info("Input data found from backend, adding to canvas");
const inputData = result.data;
// Compute backend batch hash for dedupe and state
let backendHashNow;
if (inputData?.input_images_batch && Array.isArray(inputData.input_images_batch)) {
backendHashNow = inputData.input_images_batch.map((i) => i.data).join('|');
}
else if (inputData?.input_image) {
backendHashNow = inputData.input_image;
}
// Just update the hash without removing any layers
if (backendHashNow) {
log.info("New backend input data detected, adding new layers");
this.canvas.lastLoadedImageSrc = backendHashNow;
}
// Mark that we've loaded input data for this execution
this.canvas.inputDataLoaded = true;
// Determine add mode based on fit_on_add setting
const widgets = this.canvas.node.widgets;
const fitOnAddWidget = widgets ? widgets.find((w) => w.name === "fit_on_add") : null;
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
// Load input image(s) only if image input is actually connected, not already loaded, and allowed
if (allowImage && !imageLoaded && hasImageInput) {
if (inputData.input_images_batch) {
// Handle batch of images
const batch = inputData.input_images_batch;
log.info(`Processing batch of ${batch.length} images from backend`);
for (let i = 0; i < batch.length; i++) {
const imgData = batch[i];
const img = await createImageFromSource(imgData.data);
// Add image to canvas with unique name
await this.canvas.canvasLayers.addLayerWithImage(img, { name: `Batch Image ${i + 1}` }, addMode, this.canvas.outputAreaBounds);
log.debug(`Added batch image ${i + 1}/${batch.length} from backend`);
}
log.info(`All ${batch.length} batch images added from backend`);
this.canvas.render();
this.canvas.saveState();
}
else if (inputData.input_image) {
// Handle single image (backward compatibility)
const img = await createImageFromSource(inputData.input_image);
// Add image to canvas at output area position
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode, this.canvas.outputAreaBounds);
log.info("Single input image added as new layer to canvas");
this.canvas.render();
this.canvas.saveState();
}
else {
log.debug("No input image data from backend");
}
}
else if (!hasImageInput && (inputData.input_images_batch || inputData.input_image)) {
log.debug("Backend has image data but no image input connected, skipping image load");
}
// Handle mask separately only if mask input is actually connected, allowed, and not already loaded
if (allowMask && !maskLoaded && hasMaskInput && inputData.input_mask) {
log.info("Processing input mask");
// Load mask image
const maskImg = await createImageFromSource(inputData.input_mask);
// Determine if we should fit the mask or use it at original size
const fitOnAddWidget2 = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
const shouldFit = fitOnAddWidget2 && fitOnAddWidget2.value;
let finalMaskImg = maskImg;
if (shouldFit && this.canvas.maskTool) {
const bounds = this.canvas.outputAreaBounds;
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
}
// Apply to MaskTool (centers internally)
if (this.canvas.maskTool) {
this.canvas.maskTool.setMask(finalMaskImg, true);
}
this.canvas.maskAppliedFromInput = true;
// Save the mask state
this.canvas.canvasState.saveMaskState();
log.info("Applied input mask to mask tool" + (shouldFit ? " (fitted to output area)" : " (original size)"));
}
else if (!hasMaskInput && inputData.input_mask) {
log.debug("Backend has mask data but no mask input connected, skipping mask load");
}
else if (!allowMask && inputData.input_mask) {
log.debug("Mask input data present in backend but mask loading is disabled by caller; skipping");
}
}
else {
log.debug("No input data from backend");
// Don't schedule another check - we'll only check when explicitly triggered
}
}
catch (error) {
log.error("Error checking for input data:", error);
// Don't schedule another check on error
}
}
scheduleInputDataCheck() {
// Schedule a retry for mask data check when nodeOutputs are not ready yet
if (this.canvas.pendingInputDataCheck) {
clearTimeout(this.canvas.pendingInputDataCheck);
}
this.canvas.pendingInputDataCheck = window.setTimeout(() => {
this.canvas.pendingInputDataCheck = null;
log.debug("Retrying input data check for mask...");
}, 500); // Shorter delay for mask data retry
}
scheduleDataCheck() {
if (this.canvas.pendingDataCheck) {
clearTimeout(this.canvas.pendingDataCheck);
@@ -423,51 +814,10 @@ export class CanvasIO {
}
}
convertTensorToImageData(tensor) {
try {
const shape = tensor.shape;
const height = shape[1];
const width = shape[2];
const channels = shape[3];
log.debug("Converting tensor:", {
shape: shape,
dataRange: {
min: tensor.min_val,
max: tensor.max_val
}
});
const imageData = new ImageData(width, height);
const data = new Uint8ClampedArray(width * height * 4);
const flatData = tensor.data;
const pixelCount = width * height;
for (let i = 0; i < pixelCount; i++) {
const pixelIndex = i * 4;
const tensorIndex = i * channels;
for (let c = 0; c < channels; c++) {
const value = flatData[tensorIndex + c];
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
data[pixelIndex + c] = Math.round(normalizedValue * 255);
}
data[pixelIndex + 3] = 255;
}
imageData.data.set(data);
return imageData;
}
catch (error) {
log.error("Error converting tensor:", error);
return null;
}
return tensorToImageData(tensor, 'rgb');
}
async createImageFromData(imageData) {
return new Promise((resolve, reject) => {
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create canvas context");
ctx.putImageData(imageData, 0, 0);
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = canvas.toDataURL();
});
return createImageFromImageData(imageData);
}
async processMaskData(maskData) {
try {
@@ -527,12 +877,7 @@ export class CanvasIO {
log.info(`Received ${result.images.length} new images, adding to canvas.`);
const newLayers = [];
for (const imageData of result.images) {
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = imageData;
});
const img = await createImageFromSource(imageData);
let processedImage = img;
// If there's a custom shape, clip the image to that shape
if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) {
@@ -559,33 +904,27 @@ export class CanvasIO {
}
}
async clipImageToShape(image, shape) {
return new Promise((resolve, reject) => {
const { canvas, ctx } = createCanvas(image.width, image.height);
if (!ctx) {
reject(new Error("Could not create canvas context for clipping"));
return;
}
// Draw the image first
ctx.drawImage(image, 0, 0);
// Calculate custom shape position accounting for extensions
// Custom shape should maintain its relative position within the original canvas area
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
// Create a clipping mask using the shape with extension offset
ctx.globalCompositeOperation = 'destination-in';
ctx.beginPath();
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
for (let i = 1; i < shape.points.length; i++) {
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
}
ctx.closePath();
ctx.fill();
// Create a new image from the clipped canvas
const clippedImage = new Image();
clippedImage.onload = () => resolve(clippedImage);
clippedImage.onerror = () => reject(new Error("Failed to create clipped image"));
clippedImage.src = canvas.toDataURL();
});
const { canvas, ctx } = createCanvas(image.width, image.height);
if (!ctx) {
throw new Error("Could not create canvas context for clipping");
}
// Draw the image first
ctx.drawImage(image, 0, 0);
// Calculate custom shape position accounting for extensions
// Custom shape should maintain its relative position within the original canvas area
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
// Create a clipping mask using the shape with extension offset
ctx.globalCompositeOperation = 'destination-in';
ctx.beginPath();
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
for (let i = 1; i < shape.points.length; i++) {
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
}
ctx.closePath();
ctx.fill();
// Create a new image from the clipped canvas
return await createImageFromSource(canvas.toDataURL());
}
}

View File

@@ -652,8 +652,8 @@ export class CanvasRenderer {
this.updateStrokeOverlaySize();
// Position above main canvas but below cursor overlay
this.strokeOverlayCanvas.style.position = 'absolute';
this.strokeOverlayCanvas.style.left = '1px';
this.strokeOverlayCanvas.style.top = '1px';
this.strokeOverlayCanvas.style.left = '0px';
this.strokeOverlayCanvas.style.top = '0px';
this.strokeOverlayCanvas.style.pointerEvents = 'none';
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
// Opacity is now controlled by MaskTool.previewOpacity

View File

@@ -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) {
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (maskCtx) {
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(prevState, 0, 0);
}
// Use the new restoreMaskFromSavedState method that properly clears chunks first
this.canvas.maskTool.restoreMaskFromSavedState(prevState);
// Clear stroke overlay to prevent old drawing previews from persisting
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
this.canvas.render();
}
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();
if (nextState) {
this.maskUndoStack.push(nextState);
const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (maskCtx) {
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(nextState, 0, 0);
}
// Use the new restoreMaskFromSavedState method that properly clears chunks first
this.canvas.maskTool.restoreMaskFromSavedState(nextState);
// Clear stroke overlay to prevent old drawing previews from persisting
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
this.canvas.render();
}
this.canvas.updateHistoryButtons();

View File

@@ -911,7 +911,9 @@ async function createCanvasWidget(node, widget, app) {
height: "100%"
}
}, [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}`);
let backdrop = null;
let originalParent = null;
@@ -1000,7 +1002,11 @@ async function createCanvasWidget(node, widget, app) {
if (!window.canvasExecutionStates) {
window.canvasExecutionStates = new Map();
}
node.canvasWidget = canvas;
// Store the entire widget object, not just the canvas
node.canvasWidget = {
canvas: canvas,
panel: controlPanel
};
setTimeout(() => {
canvas.loadInitialState();
if (canvas.canvasLayersPanel) {
@@ -1017,7 +1023,7 @@ async function createCanvasWidget(node, widget, app) {
if (canvas && canvas.setPreviewVisibility) {
canvas.setPreviewVisibility(value);
}
if (node.graph && node.graph.canvas) {
if (node.graph && node.graph.canvas && node.setDirtyCanvas) {
node.setDirtyCanvas(true, true);
}
};
@@ -1096,9 +1102,144 @@ app.registerExtension({
const canvasWidget = await createCanvasWidget(this, null, app);
canvasNodeInstances.set(this.id, canvasWidget);
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(() => {
this.setDirtyCanvas(true, true);
}, 100);
if (this.inputs && this.inputs.length > 0) {
// 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;
nodeType.prototype.onRemoved = function () {

View File

@@ -424,7 +424,6 @@ export class MaskEditorIntegration {
boundsPos: { x: bounds.x, y: bounds.y },
maskSize: { width: bounds.width, height: bounds.height }
});
// Use the chunk system instead of direct canvas manipulation
this.maskTool.setMask(maskAsImage);
// Update node preview using PreviewUtils
await updateNodePreview(this.canvas, this.node, true);

View File

@@ -1352,6 +1352,23 @@ export class MaskTool {
this.canvasInstance.render();
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() {
// Return the current active mask canvas which shows all chunks
// Only update if there are pending changes to avoid unnecessary redraws
@@ -1445,13 +1462,44 @@ export class MaskTool {
this.isOverlayVisible = !this.isOverlayVisible;
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
}
setMask(image) {
// Clear existing mask chunks in the output area first
setMask(image, isFromInputMask = false) {
const bounds = this.canvasInstance.outputAreaBounds;
this.clearMaskInArea(bounds.x, bounds.y, image.width, image.height);
// Add the new mask using the chunk system
this.addMask(image);
log.info(`MaskTool set new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
if (isFromInputMask) {
// For INPUT MASK - process black background to transparent using luminance
// Center like input images
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

View File

@@ -242,35 +242,61 @@ async function handleSAMDetectorResult(node, resultImage) {
// Try to reload the image with a fresh request
log.debug("Attempting to reload SAM result image");
const originalSrc = resultImage.src;
// Add cache-busting parameter to force fresh load
const url = new URL(originalSrc);
url.searchParams.set('_t', Date.now().toString());
await new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
// 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
// Check if it's a data URL (base64) - don't add parameters to data URLs
if (originalSrc.startsWith('data:')) {
log.debug("Image is a data URL, skipping reload with parameters");
// For data URLs, just ensure the image is loaded
if (!resultImage.complete || resultImage.naturalWidth === 0) {
await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resultImage.width = img.width;
resultImage.height = img.height;
log.debug("Data URL image loaded successfully", {
width: img.width,
height: img.height
});
resolve(img);
};
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) => {
log.error("Failed to reload SAM result image", {
originalSrc: originalSrc,
newSrc: url.toString(),
error: error
});
reject(error);
};
img.src = url.toString();
});
}
}
else {
// For regular URLs, add cache-busting parameter
const url = new URL(originalSrc);
url.searchParams.set('_t', Date.now().toString());
await new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
// 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) {
@@ -290,27 +316,37 @@ async function handleSAMDetectorResult(node, resultImage) {
// Apply mask to LayerForge canvas using MaskTool.setMask method
log.debug("Checking canvas and maskTool availability", {
hasCanvas: !!canvas,
hasCanvasProperty: !!canvas.canvas,
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
hasMaskTool: !!canvas.maskTool,
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
maskToolType: typeof canvas.maskTool,
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
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:", {
hasCanvas: !!canvas,
hasActualCanvas: !!actualCanvas,
canvasConstructor: canvas.constructor.name,
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
canvasKeys: Object.keys(canvas),
maskToolValue: canvas.maskTool
actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
maskToolValue: maskTool
});
throw new Error("Mask tool not available or not initialized");
}
log.debug("Applying SAM mask to canvas using addMask method");
// Use the addMask method which overlays on existing mask without clearing it
canvas.maskTool.addMask(maskAsImage);
log.debug("Applying SAM mask to canvas using setMask method");
// Use the setMask method which clears existing mask and sets new one
maskTool.setMask(maskAsImage);
// Update canvas and save state (same as MaskEditorIntegration)
canvas.render();
canvas.saveState();
actualCanvas.render();
actualCanvas.saveState();
// 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");
// Show success notification
showSuccessNotification("SAM Detector mask applied to LayerForge!");
@@ -340,13 +376,20 @@ export function setupSAMDetectorHook(node, options) {
try {
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
// Automatically send canvas to clipspace and start monitoring
if (node.canvasWidget && node.canvasWidget.canvas) {
const canvas = node.canvasWidget; // canvasWidget IS the Canvas object
// Use ImageUploadUtils to upload canvas
if (node.canvasWidget) {
const canvasWidget = node.canvasWidget;
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, {
filenamePrefix: 'layerforge-sam',
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
node.imgs = [uploadResult.imageElement];
node.clipspaceImg = uploadResult.imageElement;

View File

@@ -314,3 +314,102 @@ export function canvasToMaskImage(canvas) {
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());
}

View File

@@ -1,7 +1,7 @@
[project]
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."
version = "1.5.5"
version = "1.5.6"
license = { text = "MIT License" }
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]

View File

@@ -91,6 +91,11 @@ export class Canvas {
onStateChange: (() => void) | undefined;
pendingBatchContext: any;
pendingDataCheck: number | null;
pendingInputDataCheck: number | null;
inputDataLoaded: boolean;
lastLoadedImageSrc?: string;
lastLoadedLinkId?: number;
lastLoadedMaskLinkId?: number;
previewVisible: boolean;
requestSaveState: () => void;
viewport: Viewport;
@@ -138,6 +143,8 @@ export class Canvas {
this.dataInitialized = false;
this.pendingDataCheck = null;
this.pendingInputDataCheck = null;
this.inputDataLoaded = false;
this.imageCache = new Map();
this.requestSaveState = () => {};
@@ -483,6 +490,11 @@ export class Canvas {
};
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()) {
lastExecutionStartTime = Date.now();
// Store a snapshot of the context for the upcoming batch
@@ -506,6 +518,10 @@ export class Canvas {
};
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()) {
log.info('Auto-refresh triggered, importing latest images.');

View File

@@ -2,6 +2,7 @@ import { createCanvas } from "./utils/CommonUtils.js";
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { showErrorNotification } from "./utils/NotificationUtils.js";
import { webSocketManager } from "./utils/WebSocketManager.js";
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
import type { Canvas } from './Canvas';
import type { Layer, Shape } from './types';
@@ -282,22 +283,12 @@ export class CanvasIO {
try {
log.debug("Adding input to canvas:", { inputImage });
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
if (!tempCtx) throw new Error("Could not create temp context");
// Use unified tensorToImageData for RGB image
const imageData = tensorToImageData(inputImage, 'rgb');
if (!imageData) throw new Error("Failed to convert input image tensor");
const imgData = new ImageData(
new Uint8ClampedArray(inputImage.data),
inputImage.width,
inputImage.height
);
tempCtx.putImageData(imgData, 0, 0);
const image = new Image();
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
image.src = tempCanvas.toDataURL();
});
// Create HTMLImageElement from ImageData
const image = await createImageFromImageData(imageData);
const bounds = this.canvas.outputAreaBounds;
const scale = Math.min(
@@ -333,23 +324,10 @@ export class CanvasIO {
throw new Error("Invalid tensor data");
}
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
if (!ctx) throw new Error("Could not create canvas context");
const imageData = tensorToImageData(tensor, 'rgb');
if (!imageData) throw new Error("Failed to convert tensor to image data");
const imageData = new ImageData(
new Uint8ClampedArray(tensor.data),
tensor.width,
tensor.height
);
ctx.putImageData(imageData, 0, 0);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
img.src = canvas.toDataURL();
});
return await createImageFromImageData(imageData);
} catch (error) {
log.error("Error converting tensor to image:", error);
throw error;
@@ -372,6 +350,16 @@ export class CanvasIO {
try {
log.info("Starting node data initialization...");
// First check for input data from the backend (new feature)
await this.checkForInputData();
// If we've already loaded input data, don't continue with old initialization
if (this.canvas.inputDataLoaded) {
log.debug("Input data already loaded, skipping old initialization");
this.canvas.dataInitialized = true;
return;
}
if (!this.canvas.node || !(this.canvas.node as any).inputs) {
log.debug("Node or inputs not ready");
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) {
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];
if (imageData) {
@@ -389,6 +385,9 @@ export class CanvasIO {
log.debug("Image data not available yet");
return this.scheduleDataCheck();
}
} else {
// No input connected, mark as initialized to stop repeated checks
this.canvas.dataInitialized = true;
}
if ((this.canvas.node 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 {
if (this.canvas.pendingDataCheck) {
clearTimeout(this.canvas.pendingDataCheck);
@@ -499,59 +931,11 @@ export class CanvasIO {
}
convertTensorToImageData(tensor: any): ImageData | null {
try {
const shape = tensor.shape;
const height = shape[1];
const width = shape[2];
const channels = shape[3];
log.debug("Converting tensor:", {
shape: shape,
dataRange: {
min: tensor.min_val,
max: tensor.max_val
}
});
const imageData = new ImageData(width, height);
const data = new Uint8ClampedArray(width * height * 4);
const flatData = tensor.data;
const pixelCount = width * height;
for (let i = 0; i < pixelCount; i++) {
const pixelIndex = i * 4;
const tensorIndex = i * channels;
for (let c = 0; c < channels; c++) {
const value = flatData[tensorIndex + c];
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
data[pixelIndex + c] = Math.round(normalizedValue * 255);
}
data[pixelIndex + 3] = 255;
}
imageData.data.set(data);
return imageData;
} catch (error) {
log.error("Error converting tensor:", error);
return null;
}
return tensorToImageData(tensor, 'rgb');
}
async createImageFromData(imageData: ImageData): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
if (!ctx) throw new Error("Could not create canvas context");
ctx.putImageData(imageData, 0, 0);
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = canvas.toDataURL();
});
return createImageFromImageData(imageData);
}
async processMaskData(maskData: any): Promise<void> {
@@ -618,12 +1002,7 @@ export class CanvasIO {
const newLayers: (Layer | null)[] = [];
for (const imageData of result.images) {
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = imageData;
});
const img = await createImageFromSource(imageData);
let processedImage = img;
@@ -652,37 +1031,31 @@ export class CanvasIO {
}
async clipImageToShape(image: HTMLImageElement, shape: Shape): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const { canvas, ctx } = createCanvas(image.width, image.height);
if (!ctx) {
reject(new Error("Could not create canvas context for clipping"));
return;
}
const { canvas, ctx } = createCanvas(image.width, image.height);
if (!ctx) {
throw new Error("Could not create canvas context for clipping");
}
// Draw the image first
ctx.drawImage(image, 0, 0);
// Draw the image first
ctx.drawImage(image, 0, 0);
// Calculate custom shape position accounting for extensions
// Custom shape should maintain its relative position within the original canvas area
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
// Calculate custom shape position accounting for extensions
// Custom shape should maintain its relative position within the original canvas area
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
// Create a clipping mask using the shape with extension offset
ctx.globalCompositeOperation = 'destination-in';
ctx.beginPath();
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
for (let i = 1; i < shape.points.length; i++) {
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
}
ctx.closePath();
ctx.fill();
// Create a clipping mask using the shape with extension offset
ctx.globalCompositeOperation = 'destination-in';
ctx.beginPath();
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
for (let i = 1; i < shape.points.length; i++) {
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
}
ctx.closePath();
ctx.fill();
// Create a new image from the clipped canvas
const clippedImage = new Image();
clippedImage.onload = () => resolve(clippedImage);
clippedImage.onerror = () => reject(new Error("Failed to create clipped image"));
clippedImage.src = canvas.toDataURL();
});
// Create a new image from the clipped canvas
return await createImageFromSource(canvas.toDataURL());
}
}

View File

@@ -796,8 +796,8 @@ export class CanvasRenderer {
// Position above main canvas but below cursor overlay
this.strokeOverlayCanvas.style.position = 'absolute';
this.strokeOverlayCanvas.style.left = '1px';
this.strokeOverlayCanvas.style.top = '1px';
this.strokeOverlayCanvas.style.left = '0px';
this.strokeOverlayCanvas.style.top = '0px';
this.strokeOverlayCanvas.style.pointerEvents = 'none';
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
// Opacity is now controlled by MaskTool.previewOpacity

View File

@@ -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) {
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (maskCtx) {
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(prevState, 0, 0);
}
// Use the new restoreMaskFromSavedState method that properly clears chunks first
this.canvas.maskTool.restoreMaskFromSavedState(prevState);
// Clear stroke overlay to prevent old drawing previews from persisting
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
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();
if (nextState) {
this.maskUndoStack.push(nextState);
const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (maskCtx) {
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(nextState, 0, 0);
}
// Use the new restoreMaskFromSavedState method that properly clears chunks first
this.canvas.maskTool.restoreMaskFromSavedState(nextState);
// Clear stroke overlay to prevent old drawing previews from persisting
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
this.canvas.render();
}
this.canvas.updateHistoryButtons();

View File

@@ -1029,7 +1029,9 @@ $el("label.clipboard-switch.mask-switch", {
}
}, [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;
let backdrop: HTMLDivElement | null = null;
@@ -1141,7 +1143,12 @@ $el("label.clipboard-switch.mask-switch", {
if (!(window as any).canvasExecutionStates) {
(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(() => {
canvas.loadInitialState();
@@ -1163,7 +1170,7 @@ $el("label.clipboard-switch.mask-switch", {
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);
}
};
@@ -1255,10 +1262,156 @@ app.registerExtension({
const canvasWidget = await createCanvasWidget(this, null, app);
canvasNodeInstances.set(this.id, canvasWidget);
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(() => {
this.setDirtyCanvas(true, true);
}, 100);
if (this.inputs && this.inputs.length > 0) {
// 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;

View File

@@ -507,7 +507,6 @@ export class MaskEditorIntegration {
maskSize: {width: bounds.width, height: bounds.height}
});
// Use the chunk system instead of direct canvas manipulation
this.maskTool.setMask(maskAsImage);
// Update node preview using PreviewUtils

View File

@@ -1674,6 +1674,27 @@ export class MaskTool {
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 {
// Return the current active mask canvas which shows all chunks
// 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}`);
}
setMask(image: HTMLImageElement): void {
// Clear existing mask chunks in the output area first
setMask(image: HTMLImageElement, isFromInputMask: boolean = false): void {
const bounds = this.canvasInstance.outputAreaBounds;
this.clearMaskInArea(bounds.x, bounds.y, image.width, image.height);
// Add the new mask using the chunk system
this.addMask(image);
log.info(`MaskTool set new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
if (isFromInputMask) {
// For INPUT MASK - process black background to transparent using luminance
// Center like input images
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})`);
}
}
/**

View File

@@ -282,36 +282,61 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
log.debug("Attempting to reload SAM result image");
const originalSrc = resultImage.src;
// Add cache-busting parameter to force fresh load
const url = new URL(originalSrc);
url.searchParams.set('_t', Date.now().toString());
await new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
// 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
// Check if it's a data URL (base64) - don't add parameters to data URLs
if (originalSrc.startsWith('data:')) {
log.debug("Image is a data URL, skipping reload with parameters");
// For data URLs, just ensure the image is loaded
if (!resultImage.complete || resultImage.naturalWidth === 0) {
await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resultImage.width = img.width;
resultImage.height = img.height;
log.debug("Data URL image loaded successfully", {
width: img.width,
height: img.height
});
resolve(img);
};
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) => {
log.error("Failed to reload SAM result image", {
originalSrc: originalSrc,
newSrc: url.toString(),
error: error
});
reject(error);
};
img.src = url.toString();
});
}
} else {
// For regular URLs, add cache-busting parameter
const url = new URL(originalSrc);
url.searchParams.set('_t', Date.now().toString());
await new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
// 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) {
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
log.debug("Checking canvas and maskTool availability", {
hasCanvas: !!canvas,
hasCanvasProperty: !!canvas.canvas,
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
hasMaskTool: !!canvas.maskTool,
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
maskToolType: typeof canvas.maskTool,
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
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:", {
hasCanvas: !!canvas,
hasActualCanvas: !!actualCanvas,
canvasConstructor: canvas.constructor.name,
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
canvasKeys: Object.keys(canvas),
maskToolValue: canvas.maskTool
actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
maskToolValue: maskTool
});
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
canvas.maskTool.addMask(maskAsImage);
// Use the setMask method which clears existing mask and sets new one
maskTool.setMask(maskAsImage);
// Update canvas and save state (same as MaskEditorIntegration)
canvas.render();
canvas.saveState();
actualCanvas.render();
actualCanvas.saveState();
// 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");
@@ -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");
// Automatically send canvas to clipspace and start monitoring
if ((node as any).canvasWidget && (node as any).canvasWidget.canvas) {
const canvas = (node as any).canvasWidget; // canvasWidget IS the Canvas object
if ((node as any).canvasWidget) {
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, {
filenamePrefix: 'layerforge-sam',
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
node.imgs = [uploadResult.imageElement];
(node as any).clipspaceImg = uploadResult.imageElement;

View File

@@ -1,6 +1,14 @@
import type { Canvas as CanvasClass } from './Canvas';
import type { CanvasLayers } from './CanvasLayers';
export interface ComfyWidget {
name: string;
type: string;
value: any;
callback?: (value: any) => void;
options?: any;
}
export interface Layer {
id: string;
image: HTMLImageElement;
@@ -32,15 +40,16 @@ export interface Layer {
export interface ComfyNode {
id: number;
type: string;
widgets: ComfyWidget[];
imgs?: HTMLImageElement[];
widgets: any[];
size: [number, number];
graph: any;
canvasWidget?: any;
size?: [number, number];
onResize?: () => void;
addDOMWidget: (name: string, type: string, element: HTMLElement, options?: any) => any;
addWidget: (type: string, name: string, value: any, callback?: (value: any) => void, options?: any) => any;
setDirtyCanvas: (force: boolean, dirty: boolean) => void;
setDirtyCanvas?: (dirty: boolean, propagate: boolean) => void;
graph?: any;
onRemoved?: () => void;
addDOMWidget?: (name: string, type: string, element: HTMLElement) => void;
inputs?: Array<{ link: any }>;
}
declare global {
@@ -79,8 +88,14 @@ export interface Canvas {
imageCache: any;
dataInitialized: boolean;
pendingDataCheck: number | null;
pendingInputDataCheck: number | null;
pendingBatchContext: any;
canvasLayers: any;
inputDataLoaded: boolean;
lastLoadedLinkId: any;
lastLoadedMaskLinkId: any;
lastLoadedImageSrc?: string;
outputAreaBounds: OutputAreaBounds;
saveState: () => void;
render: () => void;
updateSelection: (layers: Layer[]) => void;

View File

@@ -386,3 +386,111 @@ export function canvasToMaskImage(canvas: HTMLCanvasElement): Promise<HTMLImageE
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());
}