mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
Refactor image, mask, and notification logic into utils
Extracted image upload, mask processing, notification, and preview update logic into dedicated utility modules. Updated MaskEditorIntegration and SAMDetectorIntegration to use these new utilities, reducing code duplication and improving maintainability.
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
106
js/utils/ImageUploadUtils.js
Normal file
106
js/utils/ImageUploadUtils.js
Normal file
@@ -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);
|
||||
}
|
||||
170
js/utils/MaskProcessingUtils.js
Normal file
170
js/utils/MaskProcessingUtils.js
Normal file
@@ -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;
|
||||
}
|
||||
58
js/utils/NotificationUtils.js
Normal file
58
js/utils/NotificationUtils.js
Normal file
@@ -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);
|
||||
}
|
||||
161
js/utils/PreviewUtils.js
Normal file
161
js/utils/PreviewUtils.js
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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<HTMLImageElement | null> => {
|
||||
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);
|
||||
|
||||
153
src/utils/ImageUploadUtils.ts
Normal file
153
src/utils/ImageUploadUtils.ts
Normal file
@@ -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<UploadImageResult> {
|
||||
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<void>((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<UploadImageResult> {
|
||||
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<Blob | null>(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<UploadImageResult> {
|
||||
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);
|
||||
}
|
||||
248
src/utils/MaskProcessingUtils.ts
Normal file
248
src/utils/MaskProcessingUtils.ts
Normal file
@@ -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<HTMLCanvasElement> {
|
||||
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<HTMLCanvasElement> {
|
||||
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<HTMLImageElement> {
|
||||
if (source instanceof HTMLImageElement) {
|
||||
return source; // Already an image
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.src = source.toDataURL();
|
||||
|
||||
await new Promise<void>((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<HTMLCanvasElement> {
|
||||
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<HTMLCanvasElement> {
|
||||
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;
|
||||
}
|
||||
66
src/utils/NotificationUtils.ts
Normal file
66
src/utils/NotificationUtils.ts
Normal file
@@ -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);
|
||||
}
|
||||
219
src/utils/PreviewUtils.ts
Normal file
219
src/utils/PreviewUtils.ts
Normal file
@@ -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<HTMLImageElement> {
|
||||
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<void>((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<HTMLImageElement> {
|
||||
log.debug('Creating preview from blob:', {
|
||||
blobSize: blob.size,
|
||||
updateNodeImages,
|
||||
hasNode: !!node
|
||||
});
|
||||
|
||||
const previewImage = new Image();
|
||||
previewImage.src = URL.createObjectURL(blob);
|
||||
|
||||
await new Promise<void>((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<HTMLImageElement> {
|
||||
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<Blob>
|
||||
): Promise<HTMLImageElement> {
|
||||
log.debug('Creating custom preview:', { nodeId: node.id });
|
||||
|
||||
const blob = await processor(canvas);
|
||||
return createPreviewFromBlob(blob, node, true);
|
||||
}
|
||||
Reference in New Issue
Block a user