diff --git a/js/MaskEditorIntegration.js b/js/MaskEditorIntegration.js index c0f57d2..3637bcd 100644 --- a/js/MaskEditorIntegration.js +++ b/js/MaskEditorIntegration.js @@ -2,9 +2,10 @@ import { app } from "../../scripts/app.js"; // @ts-ignore import { ComfyApp } from "../../scripts/app.js"; -// @ts-ignore -import { api } from "../../scripts/api.js"; import { createModuleLogger } from "./utils/LoggerUtils.js"; +import { uploadImageBlob } from "./utils/ImageUploadUtils.js"; +import { processImageToMask, processMaskForViewport, convertToImage } from "./utils/MaskProcessingUtils.js"; +import { updateNodePreview } from "./utils/PreviewUtils.js"; import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js"; const log = createModuleLogger('MaskEditorIntegration'); export class MaskEditorIntegration { @@ -56,28 +57,11 @@ export class MaskEditorIntegration { } log.debug('Canvas blob created successfully, size:', blob.size); try { - const formData = new FormData(); - const filename = `layerforge-mask-edit-${+new Date()}.png`; - formData.append("image", blob, filename); - formData.append("overwrite", "true"); - formData.append("type", "temp"); - log.debug('Uploading image to server:', filename); - const response = await api.fetchApi("/upload/image", { - method: "POST", - body: formData, + // Use ImageUploadUtils to upload the blob + const uploadResult = await uploadImageBlob(blob, { + filenamePrefix: 'layerforge-mask-edit' }); - if (!response.ok) { - throw new Error(`Failed to upload image: ${response.statusText}`); - } - const data = await response.json(); - log.debug('Image uploaded successfully:', data); - const img = new Image(); - img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); - await new Promise((res, rej) => { - img.onload = res; - img.onerror = rej; - }); - this.node.imgs = [img]; + this.node.imgs = [uploadResult.imageElement]; log.info('Opening ComfyUI mask editor'); ComfyApp.copyToClipspace(this.node); ComfyApp.clipspace_return_node = this.node; @@ -250,56 +234,16 @@ export class MaskEditorIntegration { * @param {number} targetHeight - Docelowa wysokość * @param {Object} maskColor - Kolor maski {r, g, b} * @returns {HTMLCanvasElement} Przetworzona maska - */ async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) { + */ + async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) { // Pozycja maski w świecie względem output bounds const bounds = this.canvas.outputAreaBounds; const maskWorldX = this.maskTool.x; const maskWorldY = this.maskTool.y; const panX = maskWorldX - bounds.x; const panY = maskWorldY - bounds.y; - log.info("Processing mask for editor:", { - sourceSize: { width: maskData.width, height: maskData.height }, - targetSize: { width: targetWidth, height: targetHeight }, - viewportPan: { x: panX, y: panY } - }); - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = targetWidth; - tempCanvas.height = targetHeight; - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - const sourceX = -panX; - const sourceY = -panY; - if (tempCtx) { - tempCtx.drawImage(maskData, // Źródło: pełna maska z "output area" - sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000) - sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000) - targetWidth, // sWidth: Szerokość wycinanego fragmentu - targetHeight, // sHeight: Wysokość wycinanego fragmentu - 0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0) - 0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0) - targetWidth, // dWidth: Szerokość wklejanego obrazu - targetHeight // dHeight: Wysokość wklejanego obrazu - ); - } - log.info("Mask viewport cropped correctly.", { - source: "maskData", - cropArea: { x: sourceX, y: sourceY, width: targetWidth, height: targetHeight } - }); - // Reszta kodu (zmiana koloru) pozostaje bez zmian - if (tempCtx) { - const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); - const data = imageData.data; - for (let i = 0; i < data.length; i += 4) { - const alpha = data[i + 3]; - if (alpha > 0) { - data[i] = maskColor.r; - data[i + 1] = maskColor.g; - data[i + 2] = maskColor.b; - } - } - tempCtx.putImageData(imageData, 0, 0); - } - log.info("Mask processing completed - color applied."); - return tempCanvas; + // Use MaskProcessingUtils for viewport processing + return await processMaskForViewport(maskData, targetWidth, targetHeight, { x: panX, y: panY }, maskColor); } /** * Tworzy obiekt Image z obecnej maski canvas @@ -418,52 +362,24 @@ export class MaskEditorIntegration { this.node.imgs = []; return; } - log.debug("Creating temporary canvas for mask processing"); + // Process image to mask using MaskProcessingUtils + log.debug("Processing image to mask using utils"); const bounds = this.canvas.outputAreaBounds; - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = bounds.width; - tempCanvas.height = bounds.height; - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - if (tempCtx) { - tempCtx.drawImage(resultImage, 0, 0, bounds.width, bounds.height); - log.debug("Processing image data to create mask"); - const imageData = tempCtx.getImageData(0, 0, bounds.width, bounds.height); - const data = imageData.data; - for (let i = 0; i < data.length; i += 4) { - const originalAlpha = data[i + 3]; - data[i] = 255; - data[i + 1] = 255; - data[i + 2] = 255; - data[i + 3] = 255 - originalAlpha; - } - tempCtx.putImageData(imageData, 0, 0); - } - log.debug("Converting processed mask to image"); - const maskAsImage = new Image(); - maskAsImage.src = tempCanvas.toDataURL(); - await new Promise(resolve => maskAsImage.onload = resolve); + const processedMask = await processImageToMask(resultImage, { + targetWidth: bounds.width, + targetHeight: bounds.height, + invertAlpha: true + }); + // Convert processed mask to image + const maskAsImage = await convertToImage(processedMask); log.debug("Applying mask using chunk system", { 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); - this.canvas.render(); - this.canvas.saveState(); - log.debug("Creating new preview image"); - const new_preview = new Image(); - const blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); - if (blob) { - new_preview.src = URL.createObjectURL(blob); - await new Promise(r => new_preview.onload = r); - this.node.imgs = [new_preview]; - log.debug("New preview image created successfully"); - } - else { - this.node.imgs = []; - log.warn("Failed to create preview blob"); - } - this.canvas.render(); + // Update node preview using PreviewUtils + await updateNodePreview(this.canvas, this.node, true); this.savedMaskState = null; log.info("Mask editor result processed successfully"); } diff --git a/js/SAMDetectorIntegration.js b/js/SAMDetectorIntegration.js index aa4173c..05db148 100644 --- a/js/SAMDetectorIntegration.js +++ b/js/SAMDetectorIntegration.js @@ -1,7 +1,10 @@ -import { api } from "../../scripts/api.js"; // @ts-ignore import { ComfyApp } from "../../scripts/app.js"; import { createModuleLogger } from "./utils/LoggerUtils.js"; +import { showInfoNotification, showSuccessNotification, showErrorNotification } from "./utils/NotificationUtils.js"; +import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.js"; +import { processImageToMask, convertToImage } from "./utils/MaskProcessingUtils.js"; +import { updateNodePreview } from "./utils/PreviewUtils.js"; const log = createModuleLogger('SAMDetectorIntegration'); /** * SAM Detector Integration for LayerForge @@ -10,34 +13,18 @@ const log = createModuleLogger('SAMDetectorIntegration'); // Function to register image in clipspace for Impact Pack compatibility export const registerImageInClipspace = async (node, blob) => { try { - // Upload the image to ComfyUI's temp storage for clipspace access - const formData = new FormData(); - const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Use timestamp for SAM Detector - formData.append("image", blob, filename); - formData.append("overwrite", "true"); - formData.append("type", "temp"); - const response = await api.fetchApi("/upload/image", { - method: "POST", - body: formData, + // Use ImageUploadUtils to upload the blob + const uploadResult = await uploadImageBlob(blob, { + filenamePrefix: 'layerforge-sam', + nodeId: node.id }); - if (response.ok) { - const data = await response.json(); - // Create a proper image element with the server URL - const clipspaceImg = new Image(); - clipspaceImg.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); - // Wait for image to load - await new Promise((resolve, reject) => { - clipspaceImg.onload = resolve; - clipspaceImg.onerror = reject; - }); - log.debug(`Image registered in clipspace for node ${node.id}: ${filename}`); - return clipspaceImg; - } + log.debug(`Image registered in clipspace for node ${node.id}: ${uploadResult.filename}`); + return uploadResult.imageElement; } catch (error) { log.debug("Failed to register image in clipspace:", error); + return null; } - return null; }; // Function to monitor for SAM Detector modal closure and apply masks to LayerForge export function startSAMDetectorMonitoring(node) { @@ -186,7 +173,7 @@ function handleSAMDetectorModalClosed(node) { else { log.info("No new image detected after SAM Detector modal closure"); // Show info notification - showNotification("SAM Detector closed. No mask was applied.", "#4a6cd4", 3000); + showInfoNotification("SAM Detector closed. No mask was applied."); } } else { @@ -286,45 +273,18 @@ async function handleSAMDetectorResult(node, resultImage) { } catch (error) { log.error("Failed to load image from SAM Detector.", error); - showNotification("Failed to load SAM Detector result. The mask file may not be available.", "#c54747", 5000); + showErrorNotification("Failed to load SAM Detector result. The mask file may not be available."); return; } - // Create temporary canvas for mask processing with correct positioning - log.debug("Creating temporary canvas for mask processing"); - // Get the output area bounds to position the mask correctly - const bounds = canvas.outputAreaBounds; - log.debug("Output area bounds for SAM mask positioning:", bounds); - // Create canvas sized to match the result image dimensions - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = resultImage.width; - tempCanvas.height = resultImage.height; - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - if (tempCtx) { - // Draw the result image at its natural size (no scaling) - tempCtx.drawImage(resultImage, 0, 0); - log.debug("Processing image data to create mask", { - imageWidth: resultImage.width, - imageHeight: resultImage.height, - boundsX: bounds.x, - boundsY: bounds.y - }); - const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); - const data = imageData.data; - // Convert to mask format (same as MaskEditorIntegration) - for (let i = 0; i < data.length; i += 4) { - const originalAlpha = data[i + 3]; - data[i] = 255; - data[i + 1] = 255; - data[i + 2] = 255; - data[i + 3] = 255 - originalAlpha; - } - tempCtx.putImageData(imageData, 0, 0); - } - // Convert processed mask to image (same as MaskEditorIntegration) - log.debug("Converting processed mask to image"); - const maskAsImage = new Image(); - maskAsImage.src = tempCanvas.toDataURL(); - await new Promise(resolve => maskAsImage.onload = resolve); + // Process image to mask using MaskProcessingUtils + log.debug("Processing image to mask using utils"); + const processedMask = await processImageToMask(resultImage, { + targetWidth: resultImage.width, + targetHeight: resultImage.height, + invertAlpha: true + }); + // Convert processed mask to image + const maskAsImage = await convertToImage(processedMask); // Apply mask to LayerForge canvas using MaskTool.setMask method log.debug("Checking canvas and maskTool availability", { hasCanvas: !!canvas, @@ -347,57 +307,22 @@ async function handleSAMDetectorResult(node, resultImage) { // Update canvas and save state (same as MaskEditorIntegration) canvas.render(); canvas.saveState(); - // Create new preview image (same as MaskEditorIntegration) - log.debug("Creating new preview image"); - const new_preview = new Image(); - const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); - if (blob) { - new_preview.src = URL.createObjectURL(blob); - await new Promise(r => new_preview.onload = r); - node.imgs = [new_preview]; - log.debug("New preview image created successfully"); - } - else { - log.warn("Failed to create preview blob"); - } - canvas.render(); + // Update node preview using PreviewUtils + await updateNodePreview(canvas, node, true); log.info("SAM Detector mask applied successfully to LayerForge canvas"); // Show success notification - showNotification("SAM Detector mask applied to LayerForge!", "#4a7c59", 3000); + showSuccessNotification("SAM Detector mask applied to LayerForge!"); } catch (error) { log.error("Error processing SAM Detector result:", error); // Show error notification - showNotification(`Failed to apply SAM mask: ${error.message}`, "#c54747", 5000); + showErrorNotification(`Failed to apply SAM mask: ${error.message}`); } finally { node.samMonitoringActive = false; node.samOriginalImgSrc = null; } } -// Helper function to show notifications -function showNotification(message, backgroundColor, duration) { - const notification = document.createElement('div'); - notification.style.cssText = ` - position: fixed; - top: 20px; - right: 20px; - background: ${backgroundColor}; - color: white; - padding: 12px 16px; - border-radius: 4px; - box-shadow: 0 2px 10px rgba(0,0,0,0.3); - z-index: 10001; - font-size: 14px; - `; - notification.textContent = message; - document.body.appendChild(notification); - setTimeout(() => { - if (notification.parentNode) { - notification.parentNode.removeChild(notification); - } - }, duration); -} // Function to setup SAM Detector hook in menu options export function setupSAMDetectorHook(node, options) { // Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously @@ -413,51 +338,14 @@ export function setupSAMDetectorHook(node, options) { // Automatically send canvas to clipspace and start monitoring if (node.canvasWidget && node.canvasWidget.canvas) { const canvas = node.canvasWidget; // canvasWidget IS the Canvas object - // Get the flattened canvas as blob - const blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); - if (!blob) { - throw new Error("Failed to generate canvas blob"); - } - // Upload the image to ComfyUI's temp storage - const formData = new FormData(); - const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Unique filename with timestamp - formData.append("image", blob, filename); - formData.append("overwrite", "true"); - formData.append("type", "temp"); - const response = await api.fetchApi("/upload/image", { - method: "POST", - body: formData, + // Use ImageUploadUtils to upload canvas + const uploadResult = await uploadCanvasAsImage(canvas, { + filenamePrefix: 'layerforge-sam', + nodeId: node.id }); - if (!response.ok) { - throw new Error(`Failed to upload image: ${response.statusText}`); - } - const data = await response.json(); - log.debug('Image uploaded for SAM Detector:', data); - // Create image element with proper URL - const img = new Image(); - img.crossOrigin = "anonymous"; // Add CORS support - // Wait for image to load before setting src - const imageLoadPromise = new Promise((resolve, reject) => { - img.onload = () => { - log.debug("SAM Detector image loaded successfully", { - width: img.width, - height: img.height, - src: img.src.substring(0, 100) + '...' - }); - resolve(img); - }; - img.onerror = (error) => { - log.error("Failed to load SAM Detector image", error); - reject(new Error("Failed to load uploaded image")); - }; - }); - // Set src after setting up event handlers - img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); - // Wait for image to load - await imageLoadPromise; // Set the image to the node for clipspace - node.imgs = [img]; - node.clipspaceImg = img; + node.imgs = [uploadResult.imageElement]; + node.clipspaceImg = uploadResult.imageElement; // Copy to ComfyUI clipspace ComfyApp.copyToClipspace(node); // Start monitoring for SAM Detector results diff --git a/js/utils/ImageUploadUtils.js b/js/utils/ImageUploadUtils.js new file mode 100644 index 0000000..312aa2b --- /dev/null +++ b/js/utils/ImageUploadUtils.js @@ -0,0 +1,106 @@ +import { api } from "../../../scripts/api.js"; +import { createModuleLogger } from "./LoggerUtils.js"; +const log = createModuleLogger('ImageUploadUtils'); +/** + * Uploads an image blob to ComfyUI server and returns image element + * @param blob - Image blob to upload + * @param options - Upload options + * @returns Promise with upload result + */ +export async function uploadImageBlob(blob, options = {}) { + const { filenamePrefix = 'layerforge', overwrite = true, type = 'temp', nodeId } = options; + // Generate unique filename + const timestamp = Date.now(); + const nodeIdSuffix = nodeId ? `-${nodeId}` : ''; + const filename = `${filenamePrefix}${nodeIdSuffix}-${timestamp}.png`; + log.debug('Uploading image blob:', { + filename, + blobSize: blob.size, + type, + overwrite + }); + // Create FormData + const formData = new FormData(); + formData.append("image", blob, filename); + formData.append("overwrite", overwrite.toString()); + formData.append("type", type); + // Upload to server + const response = await api.fetchApi("/upload/image", { + method: "POST", + body: formData, + }); + if (!response.ok) { + const error = new Error(`Failed to upload image: ${response.statusText}`); + log.error('Image upload failed:', error); + throw error; + } + const data = await response.json(); + log.debug('Image uploaded successfully:', data); + // Create image element with proper URL + const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); + const imageElement = new Image(); + imageElement.crossOrigin = "anonymous"; + // Wait for image to load + await new Promise((resolve, reject) => { + imageElement.onload = () => { + log.debug("Uploaded image loaded successfully", { + width: imageElement.width, + height: imageElement.height, + src: imageElement.src.substring(0, 100) + '...' + }); + resolve(); + }; + imageElement.onerror = (error) => { + log.error("Failed to load uploaded image", error); + reject(new Error("Failed to load uploaded image")); + }; + imageElement.src = imageUrl; + }); + return { + data, + filename, + imageUrl, + imageElement + }; +} +/** + * Uploads canvas content as image blob + * @param canvas - Canvas element or Canvas object with canvasLayers + * @param options - Upload options + * @returns Promise with upload result + */ +export async function uploadCanvasAsImage(canvas, options = {}) { + let blob = null; + // Handle different canvas types + if (canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') { + // LayerForge Canvas object + blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); + } + else if (canvas instanceof HTMLCanvasElement) { + // Standard HTML Canvas + blob = await new Promise(resolve => canvas.toBlob(resolve)); + } + else { + throw new Error("Unsupported canvas type"); + } + if (!blob) { + throw new Error("Failed to generate canvas blob"); + } + return uploadImageBlob(blob, options); +} +/** + * Uploads canvas with mask as image blob + * @param canvas - Canvas object with canvasLayers + * @param options - Upload options + * @returns Promise with upload result + */ +export async function uploadCanvasWithMaskAsImage(canvas, options = {}) { + if (!canvas.canvasLayers || typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob !== 'function') { + throw new Error("Canvas does not support mask operations"); + } + const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); + if (!blob) { + throw new Error("Failed to generate canvas with mask blob"); + } + return uploadImageBlob(blob, options); +} diff --git a/js/utils/MaskProcessingUtils.js b/js/utils/MaskProcessingUtils.js new file mode 100644 index 0000000..65cc3ce --- /dev/null +++ b/js/utils/MaskProcessingUtils.js @@ -0,0 +1,170 @@ +import { createModuleLogger } from "./LoggerUtils.js"; +const log = createModuleLogger('MaskProcessingUtils'); +/** + * Processes an image to create a mask with inverted alpha channel + * @param sourceImage - Source image or canvas element + * @param options - Processing options + * @returns Promise with processed mask as HTMLCanvasElement + */ +export async function processImageToMask(sourceImage, options = {}) { + const { targetWidth = sourceImage.width, targetHeight = sourceImage.height, invertAlpha = true, maskColor = { r: 255, g: 255, b: 255 } } = options; + log.debug('Processing image to mask:', { + sourceSize: { width: sourceImage.width, height: sourceImage.height }, + targetSize: { width: targetWidth, height: targetHeight }, + invertAlpha, + maskColor + }); + // Create temporary canvas for processing + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = targetWidth; + tempCanvas.height = targetHeight; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (!tempCtx) { + throw new Error("Failed to get 2D context for mask processing"); + } + // Draw the source image + tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight); + // Get image data for processing + const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); + const data = imageData.data; + // Process pixels to create mask + for (let i = 0; i < data.length; i += 4) { + const originalAlpha = data[i + 3]; + // Set RGB to mask color + data[i] = maskColor.r; // Red + data[i + 1] = maskColor.g; // Green + data[i + 2] = maskColor.b; // Blue + // Handle alpha channel + if (invertAlpha) { + data[i + 3] = 255 - originalAlpha; // Invert alpha + } + else { + data[i + 3] = originalAlpha; // Keep original alpha + } + } + // Put processed data back to canvas + tempCtx.putImageData(imageData, 0, 0); + log.debug('Mask processing completed'); + return tempCanvas; +} +/** + * Processes image data with custom pixel transformation + * @param sourceImage - Source image or canvas element + * @param pixelTransform - Custom pixel transformation function + * @param options - Processing options + * @returns Promise with processed image as HTMLCanvasElement + */ +export async function processImageWithTransform(sourceImage, pixelTransform, options = {}) { + const { targetWidth = sourceImage.width, targetHeight = sourceImage.height } = options; + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = targetWidth; + tempCanvas.height = targetHeight; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (!tempCtx) { + throw new Error("Failed to get 2D context for image processing"); + } + tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight); + const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + const [r, g, b, a] = pixelTransform(data[i], data[i + 1], data[i + 2], data[i + 3], i / 4); + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = a; + } + tempCtx.putImageData(imageData, 0, 0); + return tempCanvas; +} +/** + * Converts a canvas or image to an Image element + * @param source - Source canvas or image + * @returns Promise with Image element + */ +export async function convertToImage(source) { + if (source instanceof HTMLImageElement) { + return source; // Already an image + } + const image = new Image(); + image.src = source.toDataURL(); + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = reject; + }); + return image; +} +/** + * Crops an image to a specific region + * @param sourceImage - Source image or canvas + * @param cropArea - Crop area {x, y, width, height} + * @returns Promise with cropped image as HTMLCanvasElement + */ +export async function cropImage(sourceImage, cropArea) { + const { x, y, width, height } = cropArea; + log.debug('Cropping image:', { + sourceSize: { width: sourceImage.width, height: sourceImage.height }, + cropArea + }); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error("Failed to get 2D context for image cropping"); + } + ctx.drawImage(sourceImage, x, y, width, height, // Source rectangle + 0, 0, width, height // Destination rectangle + ); + return canvas; +} +/** + * Applies a mask to an image using viewport positioning + * @param maskImage - Mask image or canvas + * @param targetWidth - Target viewport width + * @param targetHeight - Target viewport height + * @param viewportOffset - Viewport offset {x, y} + * @param maskColor - Mask color (default: white) + * @returns Promise with processed mask for viewport + */ +export async function processMaskForViewport(maskImage, targetWidth, targetHeight, viewportOffset, maskColor = { r: 255, g: 255, b: 255 }) { + log.debug("Processing mask for viewport:", { + sourceSize: { width: maskImage.width, height: maskImage.height }, + targetSize: { width: targetWidth, height: targetHeight }, + viewportOffset + }); + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = targetWidth; + tempCanvas.height = targetHeight; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (!tempCtx) { + throw new Error("Failed to get 2D context for viewport mask processing"); + } + // Calculate source coordinates based on viewport offset + const sourceX = -viewportOffset.x; + const sourceY = -viewportOffset.y; + // Draw the mask with viewport cropping + tempCtx.drawImage(maskImage, // Source: full mask from "output area" + sourceX, // sx: Real X coordinate on large mask + sourceY, // sy: Real Y coordinate on large mask + targetWidth, // sWidth: Width of cropped fragment + targetHeight, // sHeight: Height of cropped fragment + 0, // dx: Where to paste in target canvas (always 0) + 0, // dy: Where to paste in target canvas (always 0) + targetWidth, // dWidth: Width of pasted image + targetHeight // dHeight: Height of pasted image + ); + // Apply mask color + const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i + 3]; + if (alpha > 0) { + data[i] = maskColor.r; + data[i + 1] = maskColor.g; + data[i + 2] = maskColor.b; + } + } + tempCtx.putImageData(imageData, 0, 0); + log.debug("Viewport mask processing completed"); + return tempCanvas; +} diff --git a/js/utils/NotificationUtils.js b/js/utils/NotificationUtils.js new file mode 100644 index 0000000..01ba03d --- /dev/null +++ b/js/utils/NotificationUtils.js @@ -0,0 +1,58 @@ +import { createModuleLogger } from "./LoggerUtils.js"; +const log = createModuleLogger('NotificationUtils'); +/** + * Utility functions for showing notifications to the user + */ +/** + * Shows a temporary notification to the user + * @param message - The message to show + * @param backgroundColor - Background color (default: #4a6cd4) + * @param duration - Duration in milliseconds (default: 3000) + */ +export function showNotification(message, backgroundColor = "#4a6cd4", duration = 3000) { + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: ${backgroundColor}; + color: white; + padding: 12px 16px; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + z-index: 10001; + font-size: 14px; + `; + notification.textContent = message; + document.body.appendChild(notification); + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, duration); + log.debug(`Notification shown: ${message}`); +} +/** + * Shows a success notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + */ +export function showSuccessNotification(message, duration = 3000) { + showNotification(message, "#4a7c59", duration); +} +/** + * Shows an error notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 5000) + */ +export function showErrorNotification(message, duration = 5000) { + showNotification(message, "#c54747", duration); +} +/** + * Shows an info notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + */ +export function showInfoNotification(message, duration = 3000) { + showNotification(message, "#4a6cd4", duration); +} diff --git a/js/utils/PreviewUtils.js b/js/utils/PreviewUtils.js new file mode 100644 index 0000000..c6bce1d --- /dev/null +++ b/js/utils/PreviewUtils.js @@ -0,0 +1,161 @@ +import { createModuleLogger } from "./LoggerUtils.js"; +const log = createModuleLogger('PreviewUtils'); +/** + * Creates a preview image from canvas and updates node + * @param canvas - Canvas object with canvasLayers + * @param node - ComfyUI node to update + * @param options - Preview options + * @returns Promise with created Image element + */ +export async function createPreviewFromCanvas(canvas, node, options = {}) { + const { includeMask = true, updateNodeImages = true, customBlob } = options; + log.debug('Creating preview from canvas:', { + includeMask, + updateNodeImages, + hasCustomBlob: !!customBlob, + nodeId: node.id + }); + let blob = customBlob || null; + // Get blob from canvas if not provided + if (!blob) { + if (!canvas.canvasLayers) { + throw new Error("Canvas does not have canvasLayers"); + } + if (includeMask && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function') { + blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); + } + else if (typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') { + blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); + } + else { + throw new Error("Canvas does not support required blob generation methods"); + } + } + if (!blob) { + throw new Error("Failed to generate canvas blob for preview"); + } + // Create preview image + const previewImage = new Image(); + previewImage.src = URL.createObjectURL(blob); + // Wait for image to load + await new Promise((resolve, reject) => { + previewImage.onload = () => { + log.debug("Preview image loaded successfully", { + width: previewImage.width, + height: previewImage.height, + nodeId: node.id + }); + resolve(); + }; + previewImage.onerror = (error) => { + log.error("Failed to load preview image", error); + reject(new Error("Failed to load preview image")); + }; + }); + // Update node images if requested + if (updateNodeImages) { + node.imgs = [previewImage]; + log.debug("Node images updated with new preview"); + } + return previewImage; +} +/** + * Creates a preview image from a blob + * @param blob - Image blob + * @param node - ComfyUI node to update (optional) + * @param updateNodeImages - Whether to update node.imgs (default: false) + * @returns Promise with created Image element + */ +export async function createPreviewFromBlob(blob, node, updateNodeImages = false) { + log.debug('Creating preview from blob:', { + blobSize: blob.size, + updateNodeImages, + hasNode: !!node + }); + const previewImage = new Image(); + previewImage.src = URL.createObjectURL(blob); + await new Promise((resolve, reject) => { + previewImage.onload = () => { + log.debug("Preview image from blob loaded successfully", { + width: previewImage.width, + height: previewImage.height + }); + resolve(); + }; + previewImage.onerror = (error) => { + log.error("Failed to load preview image from blob", error); + reject(new Error("Failed to load preview image from blob")); + }; + }); + if (updateNodeImages && node) { + node.imgs = [previewImage]; + log.debug("Node images updated with blob preview"); + } + return previewImage; +} +/** + * Updates node preview after canvas changes + * @param canvas - Canvas object + * @param node - ComfyUI node + * @param includeMask - Whether to include mask in preview + * @returns Promise with updated preview image + */ +export async function updateNodePreview(canvas, node, includeMask = true) { + log.info('Updating node preview:', { + nodeId: node.id, + includeMask + }); + // Trigger canvas render and save state + if (typeof canvas.render === 'function') { + canvas.render(); + } + if (typeof canvas.saveState === 'function') { + canvas.saveState(); + } + // Create new preview + const previewImage = await createPreviewFromCanvas(canvas, node, { + includeMask, + updateNodeImages: true + }); + log.info('Node preview updated successfully'); + return previewImage; +} +/** + * Clears node preview images + * @param node - ComfyUI node + */ +export function clearNodePreview(node) { + log.debug('Clearing node preview:', { nodeId: node.id }); + node.imgs = []; +} +/** + * Checks if node has preview images + * @param node - ComfyUI node + * @returns True if node has preview images + */ +export function hasNodePreview(node) { + return !!(node.imgs && node.imgs.length > 0 && node.imgs[0].src); +} +/** + * Gets the current preview image from node + * @param node - ComfyUI node + * @returns Current preview image or null + */ +export function getCurrentPreview(node) { + if (hasNodePreview(node) && node.imgs) { + return node.imgs[0]; + } + return null; +} +/** + * Creates a preview with custom processing + * @param canvas - Canvas object + * @param node - ComfyUI node + * @param processor - Custom processing function that takes canvas and returns blob + * @returns Promise with processed preview image + */ +export async function createCustomPreview(canvas, node, processor) { + log.debug('Creating custom preview:', { nodeId: node.id }); + const blob = await processor(canvas); + return createPreviewFromBlob(blob, node, true); +} diff --git a/src/MaskEditorIntegration.ts b/src/MaskEditorIntegration.ts index 1f8a5ef..157648e 100644 --- a/src/MaskEditorIntegration.ts +++ b/src/MaskEditorIntegration.ts @@ -5,6 +5,9 @@ import {ComfyApp} from "../../scripts/app.js"; // @ts-ignore import {api} from "../../scripts/api.js"; import { createModuleLogger } from "./utils/LoggerUtils.js"; +import { uploadCanvasAsImage, uploadCanvasWithMaskAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.js"; +import { processImageToMask, processMaskForViewport, convertToImage } from "./utils/MaskProcessingUtils.js"; +import { updateNodePreview } from "./utils/PreviewUtils.js"; import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js"; const log = createModuleLogger('MaskEditorIntegration'); @@ -72,34 +75,12 @@ export class MaskEditorIntegration { log.debug('Canvas blob created successfully, size:', blob.size); try { - const formData = new FormData(); - const filename = `layerforge-mask-edit-${+new Date()}.png`; - formData.append("image", blob, filename); - formData.append("overwrite", "true"); - formData.append("type", "temp"); - - log.debug('Uploading image to server:', filename); - - const response = await api.fetchApi("/upload/image", { - method: "POST", - body: formData, + // Use ImageUploadUtils to upload the blob + const uploadResult = await uploadImageBlob(blob, { + filenamePrefix: 'layerforge-mask-edit' }); - if (!response.ok) { - throw new Error(`Failed to upload image: ${response.statusText}`); - } - const data = await response.json(); - - log.debug('Image uploaded successfully:', data); - - const img = new Image(); - img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); - await new Promise((res, rej) => { - img.onload = res; - img.onerror = rej; - }); - - this.node.imgs = [img]; + this.node.imgs = [uploadResult.imageElement]; log.info('Opening ComfyUI mask editor'); ComfyApp.copyToClipspace(this.node); @@ -305,65 +286,24 @@ export class MaskEditorIntegration { * @param {number} targetHeight - Docelowa wysokość * @param {Object} maskColor - Kolor maski {r, g, b} * @returns {HTMLCanvasElement} Przetworzona maska - */async processMaskForEditor(maskData: any, targetWidth: any, targetHeight: any, maskColor: any) { - // Pozycja maski w świecie względem output bounds - const bounds = this.canvas.outputAreaBounds; - const maskWorldX = this.maskTool.x; - const maskWorldY = this.maskTool.y; - const panX = maskWorldX - bounds.x; - const panY = maskWorldY - bounds.y; + */ + async processMaskForEditor(maskData: any, targetWidth: any, targetHeight: any, maskColor: any) { + // Pozycja maski w świecie względem output bounds + const bounds = this.canvas.outputAreaBounds; + const maskWorldX = this.maskTool.x; + const maskWorldY = this.maskTool.y; + const panX = maskWorldX - bounds.x; + const panY = maskWorldY - bounds.y; - log.info("Processing mask for editor:", { - sourceSize: {width: maskData.width, height: maskData.height}, - targetSize: {width: targetWidth, height: targetHeight}, - viewportPan: {x: panX, y: panY} - }); - - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = targetWidth; - tempCanvas.height = targetHeight; - const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true}); - - const sourceX = -panX; - const sourceY = -panY; - - if (tempCtx) { - tempCtx.drawImage( - maskData, // Źródło: pełna maska z "output area" - sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000) - sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000) - targetWidth, // sWidth: Szerokość wycinanego fragmentu - targetHeight, // sHeight: Wysokość wycinanego fragmentu - 0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0) - 0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0) - targetWidth, // dWidth: Szerokość wklejanego obrazu - targetHeight // dHeight: Wysokość wklejanego obrazu - ); - } - - log.info("Mask viewport cropped correctly.", { - source: "maskData", - cropArea: {x: sourceX, y: sourceY, width: targetWidth, height: targetHeight} - }); - - // Reszta kodu (zmiana koloru) pozostaje bez zmian - if (tempCtx) { - const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); - const data = imageData.data; - - for (let i = 0; i < data.length; i += 4) { - const alpha = data[i + 3]; - if (alpha > 0) { - data[i] = maskColor.r; - data[i + 1] = maskColor.g; - data[i + 2] = maskColor.b; - } - } - tempCtx.putImageData(imageData, 0, 0); - } - log.info("Mask processing completed - color applied."); - return tempCanvas; - } + // Use MaskProcessingUtils for viewport processing + return await processMaskForViewport( + maskData, + targetWidth, + targetHeight, + { x: panX, y: panY }, + maskColor + ); + } /** * Tworzy obiekt Image z obecnej maski canvas @@ -502,35 +442,17 @@ export class MaskEditorIntegration { return; } - log.debug("Creating temporary canvas for mask processing"); + // Process image to mask using MaskProcessingUtils + log.debug("Processing image to mask using utils"); const bounds = this.canvas.outputAreaBounds; - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = bounds.width; - tempCanvas.height = bounds.height; - const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true}); + const processedMask = await processImageToMask(resultImage, { + targetWidth: bounds.width, + targetHeight: bounds.height, + invertAlpha: true + }); - if (tempCtx) { - tempCtx.drawImage(resultImage, 0, 0, bounds.width, bounds.height); - - log.debug("Processing image data to create mask"); - const imageData = tempCtx.getImageData(0, 0, bounds.width, bounds.height); - const data = imageData.data; - - for (let i = 0; i < data.length; i += 4) { - const originalAlpha = data[i + 3]; - data[i] = 255; - data[i + 1] = 255; - data[i + 2] = 255; - data[i + 3] = 255 - originalAlpha; - } - - tempCtx.putImageData(imageData, 0, 0); - } - - log.debug("Converting processed mask to image"); - const maskAsImage = new Image(); - maskAsImage.src = tempCanvas.toDataURL(); - await new Promise(resolve => maskAsImage.onload = resolve); + // Convert processed mask to image + const maskAsImage = await convertToImage(processedMask); log.debug("Applying mask using chunk system", { boundsPos: {x: bounds.x, y: bounds.y}, @@ -540,24 +462,8 @@ export class MaskEditorIntegration { // Use the chunk system instead of direct canvas manipulation this.maskTool.setMask(maskAsImage); - this.canvas.render(); - this.canvas.saveState(); - - log.debug("Creating new preview image"); - const new_preview = new Image(); - - const blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); - if (blob) { - new_preview.src = URL.createObjectURL(blob); - await new Promise(r => new_preview.onload = r); - this.node.imgs = [new_preview]; - log.debug("New preview image created successfully"); - } else { - this.node.imgs = []; - log.warn("Failed to create preview blob"); - } - - this.canvas.render(); + // Update node preview using PreviewUtils + await updateNodePreview(this.canvas, this.node, true); this.savedMaskState = null; log.info("Mask editor result processed successfully"); diff --git a/src/SAMDetectorIntegration.ts b/src/SAMDetectorIntegration.ts index 160d3d8..a5fbe73 100644 --- a/src/SAMDetectorIntegration.ts +++ b/src/SAMDetectorIntegration.ts @@ -2,6 +2,10 @@ import { api } from "../../scripts/api.js"; // @ts-ignore import { ComfyApp } from "../../scripts/app.js"; import { createModuleLogger } from "./utils/LoggerUtils.js"; +import { showInfoNotification, showSuccessNotification, showErrorNotification } from "./utils/NotificationUtils.js"; +import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.js"; +import { processImageToMask, convertToImage } from "./utils/MaskProcessingUtils.js"; +import { updateNodePreview } from "./utils/PreviewUtils.js"; import type { ComfyNode } from './types'; const log = createModuleLogger('SAMDetectorIntegration'); @@ -14,38 +18,18 @@ const log = createModuleLogger('SAMDetectorIntegration'); // Function to register image in clipspace for Impact Pack compatibility export const registerImageInClipspace = async (node: ComfyNode, blob: Blob): Promise => { try { - // Upload the image to ComfyUI's temp storage for clipspace access - const formData = new FormData(); - const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Use timestamp for SAM Detector - formData.append("image", blob, filename); - formData.append("overwrite", "true"); - formData.append("type", "temp"); - - const response = await api.fetchApi("/upload/image", { - method: "POST", - body: formData, + // Use ImageUploadUtils to upload the blob + const uploadResult = await uploadImageBlob(blob, { + filenamePrefix: 'layerforge-sam', + nodeId: node.id }); - if (response.ok) { - const data = await response.json(); - - // Create a proper image element with the server URL - const clipspaceImg = new Image(); - clipspaceImg.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); - - // Wait for image to load - await new Promise((resolve, reject) => { - clipspaceImg.onload = resolve; - clipspaceImg.onerror = reject; - }); - - log.debug(`Image registered in clipspace for node ${node.id}: ${filename}`); - return clipspaceImg; - } + log.debug(`Image registered in clipspace for node ${node.id}: ${uploadResult.filename}`); + return uploadResult.imageElement; } catch (error) { log.debug("Failed to register image in clipspace:", error); + return null; } - return null; }; // Function to monitor for SAM Detector modal closure and apply masks to LayerForge @@ -218,7 +202,7 @@ function handleSAMDetectorModalClosed(node: ComfyNode) { log.info("No new image detected after SAM Detector modal closure"); // Show info notification - showNotification("SAM Detector closed. No mask was applied.", "#4a6cd4", 3000); + showInfoNotification("SAM Detector closed. No mask was applied."); } } else { log.info("No image available after SAM Detector modal closure"); @@ -329,54 +313,20 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl } } catch (error) { log.error("Failed to load image from SAM Detector.", error); - showNotification("Failed to load SAM Detector result. The mask file may not be available.", "#c54747", 5000); + showErrorNotification("Failed to load SAM Detector result. The mask file may not be available."); return; } - // Create temporary canvas for mask processing with correct positioning - log.debug("Creating temporary canvas for mask processing"); - - // Get the output area bounds to position the mask correctly - const bounds = canvas.outputAreaBounds; - log.debug("Output area bounds for SAM mask positioning:", bounds); - - // Create canvas sized to match the result image dimensions - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = resultImage.width; - tempCanvas.height = resultImage.height; - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + // Process image to mask using MaskProcessingUtils + log.debug("Processing image to mask using utils"); + const processedMask = await processImageToMask(resultImage, { + targetWidth: resultImage.width, + targetHeight: resultImage.height, + invertAlpha: true + }); - if (tempCtx) { - // Draw the result image at its natural size (no scaling) - tempCtx.drawImage(resultImage, 0, 0); - - log.debug("Processing image data to create mask", { - imageWidth: resultImage.width, - imageHeight: resultImage.height, - boundsX: bounds.x, - boundsY: bounds.y - }); - - const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); - const data = imageData.data; - - // Convert to mask format (same as MaskEditorIntegration) - for (let i = 0; i < data.length; i += 4) { - const originalAlpha = data[i + 3]; - data[i] = 255; - data[i + 1] = 255; - data[i + 2] = 255; - data[i + 3] = 255 - originalAlpha; - } - - tempCtx.putImageData(imageData, 0, 0); - } - - // Convert processed mask to image (same as MaskEditorIntegration) - log.debug("Converting processed mask to image"); - const maskAsImage = new Image(); - maskAsImage.src = tempCanvas.toDataURL(); - await new Promise(resolve => maskAsImage.onload = resolve); + // Convert processed mask to image + const maskAsImage = await convertToImage(processedMask); // Apply mask to LayerForge canvas using MaskTool.setMask method log.debug("Checking canvas and maskTool availability", { @@ -405,61 +355,25 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl canvas.render(); canvas.saveState(); - // Create new preview image (same as MaskEditorIntegration) - log.debug("Creating new preview image"); - const new_preview = new Image(); - const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); - if (blob) { - new_preview.src = URL.createObjectURL(blob); - await new Promise(r => new_preview.onload = r); - node.imgs = [new_preview]; - log.debug("New preview image created successfully"); - } else { - log.warn("Failed to create preview blob"); - } - - canvas.render(); + // Update node preview using PreviewUtils + await updateNodePreview(canvas, node, true); log.info("SAM Detector mask applied successfully to LayerForge canvas"); // Show success notification - showNotification("SAM Detector mask applied to LayerForge!", "#4a7c59", 3000); + showSuccessNotification("SAM Detector mask applied to LayerForge!"); } catch (error: any) { log.error("Error processing SAM Detector result:", error); // Show error notification - showNotification(`Failed to apply SAM mask: ${error.message}`, "#c54747", 5000); + showErrorNotification(`Failed to apply SAM mask: ${error.message}`); } finally { (node as any).samMonitoringActive = false; (node as any).samOriginalImgSrc = null; } } -// Helper function to show notifications -function showNotification(message: string, backgroundColor: string, duration: number) { - const notification = document.createElement('div'); - notification.style.cssText = ` - position: fixed; - top: 20px; - right: 20px; - background: ${backgroundColor}; - color: white; - padding: 12px 16px; - border-radius: 4px; - box-shadow: 0 2px 10px rgba(0,0,0,0.3); - z-index: 10001; - font-size: 14px; - `; - notification.textContent = message; - document.body.appendChild(notification); - - setTimeout(() => { - if (notification.parentNode) { - notification.parentNode.removeChild(notification); - } - }, duration); -} // Function to setup SAM Detector hook in menu options export function setupSAMDetectorHook(node: ComfyNode, options: any[]) { @@ -483,60 +397,15 @@ export function setupSAMDetectorHook(node: ComfyNode, options: any[]) { if ((node as any).canvasWidget && (node as any).canvasWidget.canvas) { const canvas = (node as any).canvasWidget; // canvasWidget IS the Canvas object - // Get the flattened canvas as blob - const blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); - if (!blob) { - throw new Error("Failed to generate canvas blob"); - } - - // Upload the image to ComfyUI's temp storage - const formData = new FormData(); - const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Unique filename with timestamp - formData.append("image", blob, filename); - formData.append("overwrite", "true"); - formData.append("type", "temp"); - - const response = await api.fetchApi("/upload/image", { - method: "POST", - body: formData, + // Use ImageUploadUtils to upload canvas + const uploadResult = await uploadCanvasAsImage(canvas, { + filenamePrefix: 'layerforge-sam', + nodeId: node.id }); - if (!response.ok) { - throw new Error(`Failed to upload image: ${response.statusText}`); - } - - const data = await response.json(); - log.debug('Image uploaded for SAM Detector:', data); - - // Create image element with proper URL - const img = new Image(); - img.crossOrigin = "anonymous"; // Add CORS support - - // Wait for image to load before setting src - const imageLoadPromise = new Promise((resolve, reject) => { - img.onload = () => { - log.debug("SAM Detector image loaded successfully", { - width: img.width, - height: img.height, - src: img.src.substring(0, 100) + '...' - }); - resolve(img); - }; - img.onerror = (error) => { - log.error("Failed to load SAM Detector image", error); - reject(new Error("Failed to load uploaded image")); - }; - }); - - // Set src after setting up event handlers - img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); - - // Wait for image to load - await imageLoadPromise; - // Set the image to the node for clipspace - node.imgs = [img]; - (node as any).clipspaceImg = img; + node.imgs = [uploadResult.imageElement]; + (node as any).clipspaceImg = uploadResult.imageElement; // Copy to ComfyUI clipspace ComfyApp.copyToClipspace(node); diff --git a/src/utils/ImageUploadUtils.ts b/src/utils/ImageUploadUtils.ts new file mode 100644 index 0000000..28504cc --- /dev/null +++ b/src/utils/ImageUploadUtils.ts @@ -0,0 +1,153 @@ +import { api } from "../../../scripts/api.js"; +import { createModuleLogger } from "./LoggerUtils.js"; + +const log = createModuleLogger('ImageUploadUtils'); + +/** + * Utility functions for uploading images to ComfyUI server + */ + +export interface UploadImageOptions { + /** Custom filename prefix (default: 'layerforge') */ + filenamePrefix?: string; + /** Whether to overwrite existing files (default: true) */ + overwrite?: boolean; + /** Upload type (default: 'temp') */ + type?: string; + /** Node ID for unique filename generation */ + nodeId?: string | number; +} + +export interface UploadImageResult { + /** Server response data */ + data: any; + /** Generated filename */ + filename: string; + /** Full image URL */ + imageUrl: string; + /** Created Image element */ + imageElement: HTMLImageElement; +} + +/** + * Uploads an image blob to ComfyUI server and returns image element + * @param blob - Image blob to upload + * @param options - Upload options + * @returns Promise with upload result + */ +export async function uploadImageBlob(blob: Blob, options: UploadImageOptions = {}): Promise { + const { + filenamePrefix = 'layerforge', + overwrite = true, + type = 'temp', + nodeId + } = options; + + // Generate unique filename + const timestamp = Date.now(); + const nodeIdSuffix = nodeId ? `-${nodeId}` : ''; + const filename = `${filenamePrefix}${nodeIdSuffix}-${timestamp}.png`; + + log.debug('Uploading image blob:', { + filename, + blobSize: blob.size, + type, + overwrite + }); + + // Create FormData + const formData = new FormData(); + formData.append("image", blob, filename); + formData.append("overwrite", overwrite.toString()); + formData.append("type", type); + + // Upload to server + const response = await api.fetchApi("/upload/image", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const error = new Error(`Failed to upload image: ${response.statusText}`); + log.error('Image upload failed:', error); + throw error; + } + + const data = await response.json(); + log.debug('Image uploaded successfully:', data); + + // Create image element with proper URL + const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); + const imageElement = new Image(); + imageElement.crossOrigin = "anonymous"; + + // Wait for image to load + await new Promise((resolve, reject) => { + imageElement.onload = () => { + log.debug("Uploaded image loaded successfully", { + width: imageElement.width, + height: imageElement.height, + src: imageElement.src.substring(0, 100) + '...' + }); + resolve(); + }; + imageElement.onerror = (error) => { + log.error("Failed to load uploaded image", error); + reject(new Error("Failed to load uploaded image")); + }; + imageElement.src = imageUrl; + }); + + return { + data, + filename, + imageUrl, + imageElement + }; +} + +/** + * Uploads canvas content as image blob + * @param canvas - Canvas element or Canvas object with canvasLayers + * @param options - Upload options + * @returns Promise with upload result + */ +export async function uploadCanvasAsImage(canvas: any, options: UploadImageOptions = {}): Promise { + let blob: Blob | null = null; + + // Handle different canvas types + if (canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') { + // LayerForge Canvas object + blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); + } else if (canvas instanceof HTMLCanvasElement) { + // Standard HTML Canvas + blob = await new Promise(resolve => canvas.toBlob(resolve)); + } else { + throw new Error("Unsupported canvas type"); + } + + if (!blob) { + throw new Error("Failed to generate canvas blob"); + } + + return uploadImageBlob(blob, options); +} + +/** + * Uploads canvas with mask as image blob + * @param canvas - Canvas object with canvasLayers + * @param options - Upload options + * @returns Promise with upload result + */ +export async function uploadCanvasWithMaskAsImage(canvas: any, options: UploadImageOptions = {}): Promise { + if (!canvas.canvasLayers || typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob !== 'function') { + throw new Error("Canvas does not support mask operations"); + } + + const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); + if (!blob) { + throw new Error("Failed to generate canvas with mask blob"); + } + + return uploadImageBlob(blob, options); +} diff --git a/src/utils/MaskProcessingUtils.ts b/src/utils/MaskProcessingUtils.ts new file mode 100644 index 0000000..532a056 --- /dev/null +++ b/src/utils/MaskProcessingUtils.ts @@ -0,0 +1,248 @@ +import { createModuleLogger } from "./LoggerUtils.js"; + +const log = createModuleLogger('MaskProcessingUtils'); + +/** + * Utility functions for processing masks and image data + */ + +export interface MaskProcessingOptions { + /** Target width for the processed mask */ + targetWidth?: number; + /** Target height for the processed mask */ + targetHeight?: number; + /** Whether to invert the alpha channel (default: true) */ + invertAlpha?: boolean; + /** Mask color RGB values (default: {r: 255, g: 255, b: 255}) */ + maskColor?: { r: number; g: number; b: number }; +} + +/** + * Processes an image to create a mask with inverted alpha channel + * @param sourceImage - Source image or canvas element + * @param options - Processing options + * @returns Promise with processed mask as HTMLCanvasElement + */ +export async function processImageToMask( + sourceImage: HTMLImageElement | HTMLCanvasElement, + options: MaskProcessingOptions = {} +): Promise { + const { + targetWidth = sourceImage.width, + targetHeight = sourceImage.height, + invertAlpha = true, + maskColor = { r: 255, g: 255, b: 255 } + } = options; + + log.debug('Processing image to mask:', { + sourceSize: { width: sourceImage.width, height: sourceImage.height }, + targetSize: { width: targetWidth, height: targetHeight }, + invertAlpha, + maskColor + }); + + // Create temporary canvas for processing + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = targetWidth; + tempCanvas.height = targetHeight; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + + if (!tempCtx) { + throw new Error("Failed to get 2D context for mask processing"); + } + + // Draw the source image + tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight); + + // Get image data for processing + const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); + const data = imageData.data; + + // Process pixels to create mask + for (let i = 0; i < data.length; i += 4) { + const originalAlpha = data[i + 3]; + + // Set RGB to mask color + data[i] = maskColor.r; // Red + data[i + 1] = maskColor.g; // Green + data[i + 2] = maskColor.b; // Blue + + // Handle alpha channel + if (invertAlpha) { + data[i + 3] = 255 - originalAlpha; // Invert alpha + } else { + data[i + 3] = originalAlpha; // Keep original alpha + } + } + + // Put processed data back to canvas + tempCtx.putImageData(imageData, 0, 0); + + log.debug('Mask processing completed'); + return tempCanvas; +} + +/** + * Processes image data with custom pixel transformation + * @param sourceImage - Source image or canvas element + * @param pixelTransform - Custom pixel transformation function + * @param options - Processing options + * @returns Promise with processed image as HTMLCanvasElement + */ +export async function processImageWithTransform( + sourceImage: HTMLImageElement | HTMLCanvasElement, + pixelTransform: (r: number, g: number, b: number, a: number, index: number) => [number, number, number, number], + options: MaskProcessingOptions = {} +): Promise { + const { + targetWidth = sourceImage.width, + targetHeight = sourceImage.height + } = options; + + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = targetWidth; + tempCanvas.height = targetHeight; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + + if (!tempCtx) { + throw new Error("Failed to get 2D context for image processing"); + } + + tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight); + const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); + const data = imageData.data; + + for (let i = 0; i < data.length; i += 4) { + const [r, g, b, a] = pixelTransform(data[i], data[i + 1], data[i + 2], data[i + 3], i / 4); + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = a; + } + + tempCtx.putImageData(imageData, 0, 0); + return tempCanvas; +} + +/** + * Converts a canvas or image to an Image element + * @param source - Source canvas or image + * @returns Promise with Image element + */ +export async function convertToImage(source: HTMLCanvasElement | HTMLImageElement): Promise { + if (source instanceof HTMLImageElement) { + return source; // Already an image + } + + const image = new Image(); + image.src = source.toDataURL(); + + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = reject; + }); + + return image; +} + +/** + * Crops an image to a specific region + * @param sourceImage - Source image or canvas + * @param cropArea - Crop area {x, y, width, height} + * @returns Promise with cropped image as HTMLCanvasElement + */ +export async function cropImage( + sourceImage: HTMLImageElement | HTMLCanvasElement, + cropArea: { x: number; y: number; width: number; height: number } +): Promise { + const { x, y, width, height } = cropArea; + + log.debug('Cropping image:', { + sourceSize: { width: sourceImage.width, height: sourceImage.height }, + cropArea + }); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error("Failed to get 2D context for image cropping"); + } + + ctx.drawImage( + sourceImage, + x, y, width, height, // Source rectangle + 0, 0, width, height // Destination rectangle + ); + + return canvas; +} + +/** + * Applies a mask to an image using viewport positioning + * @param maskImage - Mask image or canvas + * @param targetWidth - Target viewport width + * @param targetHeight - Target viewport height + * @param viewportOffset - Viewport offset {x, y} + * @param maskColor - Mask color (default: white) + * @returns Promise with processed mask for viewport + */ +export async function processMaskForViewport( + maskImage: HTMLImageElement | HTMLCanvasElement, + targetWidth: number, + targetHeight: number, + viewportOffset: { x: number; y: number }, + maskColor: { r: number; g: number; b: number } = { r: 255, g: 255, b: 255 } +): Promise { + log.debug("Processing mask for viewport:", { + sourceSize: { width: maskImage.width, height: maskImage.height }, + targetSize: { width: targetWidth, height: targetHeight }, + viewportOffset + }); + + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = targetWidth; + tempCanvas.height = targetHeight; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + + if (!tempCtx) { + throw new Error("Failed to get 2D context for viewport mask processing"); + } + + // Calculate source coordinates based on viewport offset + const sourceX = -viewportOffset.x; + const sourceY = -viewportOffset.y; + + // Draw the mask with viewport cropping + tempCtx.drawImage( + maskImage, // Source: full mask from "output area" + sourceX, // sx: Real X coordinate on large mask + sourceY, // sy: Real Y coordinate on large mask + targetWidth, // sWidth: Width of cropped fragment + targetHeight, // sHeight: Height of cropped fragment + 0, // dx: Where to paste in target canvas (always 0) + 0, // dy: Where to paste in target canvas (always 0) + targetWidth, // dWidth: Width of pasted image + targetHeight // dHeight: Height of pasted image + ); + + // Apply mask color + const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); + const data = imageData.data; + + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i + 3]; + if (alpha > 0) { + data[i] = maskColor.r; + data[i + 1] = maskColor.g; + data[i + 2] = maskColor.b; + } + } + + tempCtx.putImageData(imageData, 0, 0); + log.debug("Viewport mask processing completed"); + + return tempCanvas; +} diff --git a/src/utils/NotificationUtils.ts b/src/utils/NotificationUtils.ts new file mode 100644 index 0000000..466a2ff --- /dev/null +++ b/src/utils/NotificationUtils.ts @@ -0,0 +1,66 @@ +import { createModuleLogger } from "./LoggerUtils.js"; + +const log = createModuleLogger('NotificationUtils'); + +/** + * Utility functions for showing notifications to the user + */ + +/** + * Shows a temporary notification to the user + * @param message - The message to show + * @param backgroundColor - Background color (default: #4a6cd4) + * @param duration - Duration in milliseconds (default: 3000) + */ +export function showNotification(message: string, backgroundColor: string = "#4a6cd4", duration: number = 3000): void { + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: ${backgroundColor}; + color: white; + padding: 12px 16px; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + z-index: 10001; + font-size: 14px; + `; + notification.textContent = message; + document.body.appendChild(notification); + + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, duration); + + log.debug(`Notification shown: ${message}`); +} + +/** + * Shows a success notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + */ +export function showSuccessNotification(message: string, duration: number = 3000): void { + showNotification(message, "#4a7c59", duration); +} + +/** + * Shows an error notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 5000) + */ +export function showErrorNotification(message: string, duration: number = 5000): void { + showNotification(message, "#c54747", duration); +} + +/** + * Shows an info notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + */ +export function showInfoNotification(message: string, duration: number = 3000): void { + showNotification(message, "#4a6cd4", duration); +} diff --git a/src/utils/PreviewUtils.ts b/src/utils/PreviewUtils.ts new file mode 100644 index 0000000..b55aa7b --- /dev/null +++ b/src/utils/PreviewUtils.ts @@ -0,0 +1,219 @@ +import { createModuleLogger } from "./LoggerUtils.js"; +import type { ComfyNode } from '../types'; + +const log = createModuleLogger('PreviewUtils'); + +/** + * Utility functions for creating and managing preview images + */ + +export interface PreviewOptions { + /** Whether to include mask in the preview (default: true) */ + includeMask?: boolean; + /** Whether to update node.imgs array (default: true) */ + updateNodeImages?: boolean; + /** Custom blob source instead of canvas */ + customBlob?: Blob; +} + +/** + * Creates a preview image from canvas and updates node + * @param canvas - Canvas object with canvasLayers + * @param node - ComfyUI node to update + * @param options - Preview options + * @returns Promise with created Image element + */ +export async function createPreviewFromCanvas( + canvas: any, + node: ComfyNode, + options: PreviewOptions = {} +): Promise { + const { + includeMask = true, + updateNodeImages = true, + customBlob + } = options; + + log.debug('Creating preview from canvas:', { + includeMask, + updateNodeImages, + hasCustomBlob: !!customBlob, + nodeId: node.id + }); + + let blob: Blob | null = customBlob || null; + + // Get blob from canvas if not provided + if (!blob) { + if (!canvas.canvasLayers) { + throw new Error("Canvas does not have canvasLayers"); + } + + if (includeMask && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function') { + blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); + } else if (typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') { + blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); + } else { + throw new Error("Canvas does not support required blob generation methods"); + } + } + + if (!blob) { + throw new Error("Failed to generate canvas blob for preview"); + } + + // Create preview image + const previewImage = new Image(); + previewImage.src = URL.createObjectURL(blob); + + // Wait for image to load + await new Promise((resolve, reject) => { + previewImage.onload = () => { + log.debug("Preview image loaded successfully", { + width: previewImage.width, + height: previewImage.height, + nodeId: node.id + }); + resolve(); + }; + previewImage.onerror = (error) => { + log.error("Failed to load preview image", error); + reject(new Error("Failed to load preview image")); + }; + }); + + // Update node images if requested + if (updateNodeImages) { + node.imgs = [previewImage]; + log.debug("Node images updated with new preview"); + } + + return previewImage; +} + +/** + * Creates a preview image from a blob + * @param blob - Image blob + * @param node - ComfyUI node to update (optional) + * @param updateNodeImages - Whether to update node.imgs (default: false) + * @returns Promise with created Image element + */ +export async function createPreviewFromBlob( + blob: Blob, + node?: ComfyNode, + updateNodeImages: boolean = false +): Promise { + log.debug('Creating preview from blob:', { + blobSize: blob.size, + updateNodeImages, + hasNode: !!node + }); + + const previewImage = new Image(); + previewImage.src = URL.createObjectURL(blob); + + await new Promise((resolve, reject) => { + previewImage.onload = () => { + log.debug("Preview image from blob loaded successfully", { + width: previewImage.width, + height: previewImage.height + }); + resolve(); + }; + previewImage.onerror = (error) => { + log.error("Failed to load preview image from blob", error); + reject(new Error("Failed to load preview image from blob")); + }; + }); + + if (updateNodeImages && node) { + node.imgs = [previewImage]; + log.debug("Node images updated with blob preview"); + } + + return previewImage; +} + +/** + * Updates node preview after canvas changes + * @param canvas - Canvas object + * @param node - ComfyUI node + * @param includeMask - Whether to include mask in preview + * @returns Promise with updated preview image + */ +export async function updateNodePreview( + canvas: any, + node: ComfyNode, + includeMask: boolean = true +): Promise { + log.info('Updating node preview:', { + nodeId: node.id, + includeMask + }); + + // Trigger canvas render and save state + if (typeof canvas.render === 'function') { + canvas.render(); + } + + if (typeof canvas.saveState === 'function') { + canvas.saveState(); + } + + // Create new preview + const previewImage = await createPreviewFromCanvas(canvas, node, { + includeMask, + updateNodeImages: true + }); + + log.info('Node preview updated successfully'); + return previewImage; +} + +/** + * Clears node preview images + * @param node - ComfyUI node + */ +export function clearNodePreview(node: ComfyNode): void { + log.debug('Clearing node preview:', { nodeId: node.id }); + node.imgs = []; +} + +/** + * Checks if node has preview images + * @param node - ComfyUI node + * @returns True if node has preview images + */ +export function hasNodePreview(node: ComfyNode): boolean { + return !!(node.imgs && node.imgs.length > 0 && node.imgs[0].src); +} + +/** + * Gets the current preview image from node + * @param node - ComfyUI node + * @returns Current preview image or null + */ +export function getCurrentPreview(node: ComfyNode): HTMLImageElement | null { + if (hasNodePreview(node) && node.imgs) { + return node.imgs[0]; + } + return null; +} + +/** + * Creates a preview with custom processing + * @param canvas - Canvas object + * @param node - ComfyUI node + * @param processor - Custom processing function that takes canvas and returns blob + * @returns Promise with processed preview image + */ +export async function createCustomPreview( + canvas: any, + node: ComfyNode, + processor: (canvas: any) => Promise +): Promise { + log.debug('Creating custom preview:', { nodeId: node.id }); + + const blob = await processor(canvas); + return createPreviewFromBlob(blob, node, true); +}