mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
Add blend area effect for layers with distance field mask
Introduces a 'blendArea' property to layers and UI controls for adjusting it. Implements distance field mask generation in ImageAnalysis.ts and applies the mask during layer rendering for smooth edge blending. Refactors CanvasRenderer to delegate layer drawing to CanvasLayers for proper blend area support.
This commit is contained in:
@@ -7,6 +7,7 @@ import { app } from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../scripts/app.js";
|
||||
import { ClipboardManager } from "./utils/ClipboardManager.js";
|
||||
import { createDistanceFieldMask } from "./utils/ImageAnalysis.js";
|
||||
const log = createModuleLogger('CanvasLayers');
|
||||
export class CanvasLayers {
|
||||
constructor(canvas) {
|
||||
@@ -100,6 +101,7 @@ export class CanvasLayers {
|
||||
}, 'CanvasLayers.addLayerWithImage');
|
||||
this.canvas = canvas;
|
||||
this.clipboardManager = new ClipboardManager(canvas);
|
||||
this.distanceFieldCache = new WeakMap();
|
||||
this.blendModes = [
|
||||
{ name: 'normal', label: 'Normal' },
|
||||
{ name: 'multiply', label: 'Multiply' },
|
||||
@@ -348,8 +350,6 @@ export class CanvasLayers {
|
||||
return;
|
||||
const { offsetX = 0, offsetY = 0 } = options;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
const centerX = layer.x + layer.width / 2 - offsetX;
|
||||
const centerY = layer.y + layer.height / 2 - offsetY;
|
||||
ctx.translate(centerX, centerY);
|
||||
@@ -361,9 +361,78 @@ export class CanvasLayers {
|
||||
}
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
// Check if we need to apply blend area effect
|
||||
const blendArea = layer.blendArea ?? 0;
|
||||
const needsBlendAreaEffect = blendArea > 0;
|
||||
log.info(`Drawing layer ${layer.id}: blendArea=${blendArea}, needsBlendAreaEffect=${needsBlendAreaEffect}`);
|
||||
if (needsBlendAreaEffect) {
|
||||
log.info(`Applying blend area effect for layer ${layer.id}`);
|
||||
// Get or create distance field mask
|
||||
let maskCanvas = this.getDistanceFieldMask(layer.image, blendArea);
|
||||
if (maskCanvas) {
|
||||
// Create a temporary canvas for the masked layer
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = layer.width;
|
||||
tempCanvas.height = layer.height;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
if (tempCtx) {
|
||||
// Draw the original image
|
||||
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
||||
// Apply the distance field mask using destination-in for transparency effect
|
||||
tempCtx.globalCompositeOperation = 'destination-in';
|
||||
tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height);
|
||||
// Draw the result
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
else {
|
||||
// Fallback to normal drawing
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Fallback to normal drawing
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Normal drawing without blend area effect
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
getDistanceFieldMask(image, blendArea) {
|
||||
// Check cache first
|
||||
let imageCache = this.distanceFieldCache.get(image);
|
||||
if (!imageCache) {
|
||||
imageCache = new Map();
|
||||
this.distanceFieldCache.set(image, imageCache);
|
||||
}
|
||||
let maskCanvas = imageCache.get(blendArea);
|
||||
if (!maskCanvas) {
|
||||
try {
|
||||
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
|
||||
maskCanvas = createDistanceFieldMask(image, blendArea);
|
||||
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
||||
imageCache.set(blendArea, maskCanvas);
|
||||
}
|
||||
catch (error) {
|
||||
log.error('Failed to create distance field mask:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
|
||||
}
|
||||
return maskCanvas;
|
||||
}
|
||||
_drawLayers(ctx, layers, options = {}) {
|
||||
const sortedLayers = [...layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => {
|
||||
@@ -551,6 +620,31 @@ export class CanvasLayers {
|
||||
content.style.cssText = `padding: 5px;`;
|
||||
menu.appendChild(titleBar);
|
||||
menu.appendChild(content);
|
||||
const blendAreaContainer = document.createElement('div');
|
||||
blendAreaContainer.style.cssText = `padding: 5px 10px; border-bottom: 1px solid #4a4a4a;`;
|
||||
const blendAreaLabel = document.createElement('label');
|
||||
blendAreaLabel.textContent = 'Blend Area';
|
||||
blendAreaLabel.style.color = 'white';
|
||||
const blendAreaSlider = document.createElement('input');
|
||||
blendAreaSlider.type = 'range';
|
||||
blendAreaSlider.min = '0';
|
||||
blendAreaSlider.max = '100';
|
||||
const selectedLayerForBlendArea = this.canvas.canvasSelection.selectedLayers[0];
|
||||
blendAreaSlider.value = selectedLayerForBlendArea?.blendArea?.toString() ?? '0';
|
||||
blendAreaSlider.oninput = () => {
|
||||
if (selectedLayerForBlendArea) {
|
||||
const newValue = parseInt(blendAreaSlider.value, 10);
|
||||
selectedLayerForBlendArea.blendArea = newValue;
|
||||
log.info(`Blend Area changed to: ${newValue}% for layer: ${selectedLayerForBlendArea.id}`);
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
blendAreaSlider.addEventListener('change', () => {
|
||||
this.canvas.saveState();
|
||||
});
|
||||
blendAreaContainer.appendChild(blendAreaLabel);
|
||||
blendAreaContainer.appendChild(blendAreaSlider);
|
||||
content.appendChild(blendAreaContainer);
|
||||
let isDragging = false;
|
||||
let dragOffset = { x: 0, y: 0 };
|
||||
const handleMouseMove = (e) => {
|
||||
@@ -598,8 +692,17 @@ export class CanvasLayers {
|
||||
option.style.backgroundColor = '#3a3a3a';
|
||||
}
|
||||
option.onclick = () => {
|
||||
content.querySelectorAll('input[type="range"]').forEach(s => s.style.display = 'none');
|
||||
content.querySelectorAll('.blend-mode-container div').forEach(d => d.style.backgroundColor = '');
|
||||
// Hide only the opacity sliders within other blend mode containers
|
||||
content.querySelectorAll('.blend-mode-container').forEach(c => {
|
||||
const opacitySlider = c.querySelector('input[type="range"]');
|
||||
if (opacitySlider) {
|
||||
opacitySlider.style.display = 'none';
|
||||
}
|
||||
const optionDiv = c.querySelector('div');
|
||||
if (optionDiv) {
|
||||
optionDiv.style.backgroundColor = '';
|
||||
}
|
||||
});
|
||||
slider.style.display = 'block';
|
||||
option.style.backgroundColor = '#3a3a3a';
|
||||
if (selectedLayer) {
|
||||
|
||||
@@ -44,34 +44,27 @@ export class CanvasRenderer {
|
||||
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
|
||||
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
|
||||
this.drawGrid(ctx);
|
||||
// Use CanvasLayers to draw layers with proper blend area support
|
||||
this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
|
||||
// Draw selection frames for selected layers
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => {
|
||||
if (!layer.image || !layer.visible)
|
||||
return;
|
||||
ctx.save();
|
||||
const currentTransform = ctx.getTransform();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.setTransform(currentTransform);
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||
const scaleH = layer.flipH ? -1 : 1;
|
||||
const scaleV = layer.flipV ? -1 : 1;
|
||||
if (layer.flipH || layer.flipV) {
|
||||
ctx.scale(scaleH, scaleV);
|
||||
}
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
if (layer.mask) {
|
||||
}
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
ctx.save();
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||
const scaleH = layer.flipH ? -1 : 1;
|
||||
const scaleV = layer.flipV ? -1 : 1;
|
||||
if (layer.flipH || layer.flipV) {
|
||||
ctx.scale(scaleH, scaleV);
|
||||
}
|
||||
this.drawSelectionFrame(ctx, layer);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
this.drawCanvasOutline(ctx);
|
||||
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
||||
|
||||
208
js/utils/ImageAnalysis.js
Normal file
208
js/utils/ImageAnalysis.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
const log = createModuleLogger('ImageAnalysis');
|
||||
/**
|
||||
* Creates a distance field mask based on the alpha channel of an image.
|
||||
* The mask will have gradients from the edges of visible pixels inward.
|
||||
* @param image - The source image to analyze
|
||||
* @param blendArea - The percentage (0-100) of the area to apply blending
|
||||
* @returns HTMLCanvasElement containing the distance field mask
|
||||
*/
|
||||
export function createDistanceFieldMask(image, blendArea) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) {
|
||||
log.error('Failed to create canvas context for distance field mask');
|
||||
return canvas;
|
||||
}
|
||||
// Draw the image to extract pixel data
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
// Check if image has transparency (any alpha < 255)
|
||||
let hasTransparency = false;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
if (data[i * 4 + 3] < 255) {
|
||||
hasTransparency = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let distanceField;
|
||||
let maxDistance;
|
||||
if (hasTransparency) {
|
||||
// For images with transparency, use alpha-based distance transform
|
||||
const binaryMask = new Uint8Array(width * height);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
binaryMask[i] = data[i * 4 + 3] > 0 ? 1 : 0;
|
||||
}
|
||||
distanceField = calculateDistanceTransform(binaryMask, width, height);
|
||||
}
|
||||
else {
|
||||
// For opaque images, calculate distance from edges of the rectangle
|
||||
distanceField = calculateDistanceFromEdges(width, height);
|
||||
}
|
||||
// Find the maximum distance to normalize
|
||||
maxDistance = 0;
|
||||
for (let i = 0; i < distanceField.length; i++) {
|
||||
if (distanceField[i] > maxDistance) {
|
||||
maxDistance = distanceField[i];
|
||||
}
|
||||
}
|
||||
// Create the gradient mask based on blendArea
|
||||
const maskData = ctx.createImageData(width, height);
|
||||
const threshold = maxDistance * (blendArea / 100);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const distance = distanceField[i];
|
||||
const alpha = data[i * 4 + 3];
|
||||
if (alpha === 0) {
|
||||
// Transparent pixels remain transparent
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = 0;
|
||||
}
|
||||
else if (distance <= threshold) {
|
||||
// Edge area - apply gradient alpha
|
||||
const gradientValue = distance / threshold;
|
||||
const alphaValue = Math.floor(gradientValue * 255);
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = alphaValue;
|
||||
}
|
||||
else {
|
||||
// Inner area - full alpha (no blending effect)
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
// Clear canvas and put the mask data
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.putImageData(maskData, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
/**
|
||||
* Calculates the Euclidean distance transform of a binary mask.
|
||||
* Uses a two-pass algorithm for efficiency.
|
||||
* @param binaryMask - Binary mask where 1 = inside, 0 = outside
|
||||
* @param width - Width of the mask
|
||||
* @param height - Height of the mask
|
||||
* @returns Float32Array containing distance values
|
||||
*/
|
||||
function calculateDistanceTransform(binaryMask, width, height) {
|
||||
const distances = new Float32Array(width * height);
|
||||
const infinity = width + height; // A value larger than any possible distance
|
||||
// Initialize distances
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
distances[i] = binaryMask[i] === 1 ? infinity : 0;
|
||||
}
|
||||
// Forward pass (top-left to bottom-right)
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
// Check top neighbor
|
||||
if (y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + x] + 1);
|
||||
}
|
||||
// Check left neighbor
|
||||
if (x > 0) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x - 1)] + 1);
|
||||
}
|
||||
// Check top-left diagonal
|
||||
if (x > 0 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
// Check top-right diagonal
|
||||
if (x < width - 1 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backward pass (bottom-right to top-left)
|
||||
for (let y = height - 1; y >= 0; y--) {
|
||||
for (let x = width - 1; x >= 0; x--) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
// Check bottom neighbor
|
||||
if (y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + x] + 1);
|
||||
}
|
||||
// Check right neighbor
|
||||
if (x < width - 1) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x + 1)] + 1);
|
||||
}
|
||||
// Check bottom-right diagonal
|
||||
if (x < width - 1 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
// Check bottom-left diagonal
|
||||
if (x > 0 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
return distances;
|
||||
}
|
||||
/**
|
||||
* Calculates distance from edges of a rectangle for opaque images.
|
||||
* @param width - Width of the rectangle
|
||||
* @param height - Height of the rectangle
|
||||
* @returns Float32Array containing distance values from edges
|
||||
*/
|
||||
function calculateDistanceFromEdges(width, height) {
|
||||
const distances = new Float32Array(width * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
// Calculate distance to nearest edge
|
||||
const distToLeft = x;
|
||||
const distToRight = width - 1 - x;
|
||||
const distToTop = y;
|
||||
const distToBottom = height - 1 - y;
|
||||
// Minimum distance to any edge
|
||||
const minDistToEdge = Math.min(distToLeft, distToRight, distToTop, distToBottom);
|
||||
distances[idx] = minDistToEdge;
|
||||
}
|
||||
}
|
||||
return distances;
|
||||
}
|
||||
/**
|
||||
* Creates a simple radial gradient mask (fallback for rectangular areas).
|
||||
* @param width - Width of the mask
|
||||
* @param height - Height of the mask
|
||||
* @param blendArea - The percentage (0-100) of the area to apply blending
|
||||
* @returns HTMLCanvasElement containing the radial gradient mask
|
||||
*/
|
||||
export function createRadialGradientMask(width, height, blendArea) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
log.error('Failed to create canvas context for radial gradient mask');
|
||||
return canvas;
|
||||
}
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
|
||||
const innerRadius = maxRadius * (1 - blendArea / 100);
|
||||
// Create radial gradient
|
||||
const gradient = ctx.createRadialGradient(centerX, centerY, innerRadius, centerX, centerY, maxRadius);
|
||||
gradient.addColorStop(0, 'white');
|
||||
gradient.addColorStop(1, 'black');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
return canvas;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {app} from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import {ComfyApp} from "../../scripts/app.js";
|
||||
import { ClipboardManager } from "./utils/ClipboardManager.js";
|
||||
import { createDistanceFieldMask } from "./utils/ImageAnalysis.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer, Point, AddMode, ClipboardPreference } from './types';
|
||||
|
||||
@@ -26,10 +27,12 @@ export class CanvasLayers {
|
||||
private isAdjustingOpacity: boolean;
|
||||
public internalClipboard: Layer[];
|
||||
public clipboardPreference: ClipboardPreference;
|
||||
private distanceFieldCache: WeakMap<HTMLImageElement, Map<number, HTMLCanvasElement>>;
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this.clipboardManager = new ClipboardManager(canvas as any);
|
||||
this.distanceFieldCache = new WeakMap();
|
||||
this.blendModes = [
|
||||
{ name: 'normal', label: 'Normal' },
|
||||
{name: 'multiply', label: 'Multiply'},
|
||||
@@ -401,9 +404,7 @@ export class CanvasLayers {
|
||||
const { offsetX = 0, offsetY = 0 } = options;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
|
||||
|
||||
const centerX = layer.x + layer.width / 2 - offsetX;
|
||||
const centerY = layer.y + layer.height / 2 - offsetY;
|
||||
|
||||
@@ -418,14 +419,85 @@ export class CanvasLayers {
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(
|
||||
layer.image,
|
||||
-layer.width / 2, -layer.height / 2,
|
||||
layer.width, layer.height
|
||||
);
|
||||
|
||||
// Check if we need to apply blend area effect
|
||||
const blendArea = layer.blendArea ?? 0;
|
||||
const needsBlendAreaEffect = blendArea > 0;
|
||||
|
||||
log.info(`Drawing layer ${layer.id}: blendArea=${blendArea}, needsBlendAreaEffect=${needsBlendAreaEffect}`);
|
||||
|
||||
if (needsBlendAreaEffect) {
|
||||
log.info(`Applying blend area effect for layer ${layer.id}`);
|
||||
// Get or create distance field mask
|
||||
let maskCanvas = this.getDistanceFieldMask(layer.image, blendArea);
|
||||
|
||||
if (maskCanvas) {
|
||||
// Create a temporary canvas for the masked layer
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = layer.width;
|
||||
tempCanvas.height = layer.height;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
if (tempCtx) {
|
||||
// Draw the original image
|
||||
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
||||
|
||||
// Apply the distance field mask using destination-in for transparency effect
|
||||
tempCtx.globalCompositeOperation = 'destination-in';
|
||||
tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height);
|
||||
|
||||
// Draw the result
|
||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
} else {
|
||||
// Fallback to normal drawing
|
||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
} else {
|
||||
// Fallback to normal drawing
|
||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
} else {
|
||||
// Normal drawing without blend area effect
|
||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private getDistanceFieldMask(image: HTMLImageElement, blendArea: number): HTMLCanvasElement | null {
|
||||
// Check cache first
|
||||
let imageCache = this.distanceFieldCache.get(image);
|
||||
if (!imageCache) {
|
||||
imageCache = new Map();
|
||||
this.distanceFieldCache.set(image, imageCache);
|
||||
}
|
||||
|
||||
let maskCanvas = imageCache.get(blendArea);
|
||||
if (!maskCanvas) {
|
||||
try {
|
||||
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
|
||||
maskCanvas = createDistanceFieldMask(image, blendArea);
|
||||
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
||||
imageCache.set(blendArea, maskCanvas);
|
||||
} catch (error) {
|
||||
log.error('Failed to create distance field mask:', error);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
|
||||
}
|
||||
|
||||
return maskCanvas;
|
||||
}
|
||||
|
||||
private _drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
|
||||
const sortedLayers = [...layers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => {
|
||||
@@ -638,6 +710,38 @@ export class CanvasLayers {
|
||||
menu.appendChild(titleBar);
|
||||
menu.appendChild(content);
|
||||
|
||||
const blendAreaContainer = document.createElement('div');
|
||||
blendAreaContainer.style.cssText = `padding: 5px 10px; border-bottom: 1px solid #4a4a4a;`;
|
||||
|
||||
const blendAreaLabel = document.createElement('label');
|
||||
blendAreaLabel.textContent = 'Blend Area';
|
||||
blendAreaLabel.style.color = 'white';
|
||||
|
||||
const blendAreaSlider = document.createElement('input');
|
||||
blendAreaSlider.type = 'range';
|
||||
blendAreaSlider.min = '0';
|
||||
blendAreaSlider.max = '100';
|
||||
|
||||
const selectedLayerForBlendArea = this.canvas.canvasSelection.selectedLayers[0];
|
||||
blendAreaSlider.value = selectedLayerForBlendArea?.blendArea?.toString() ?? '0';
|
||||
|
||||
blendAreaSlider.oninput = () => {
|
||||
if (selectedLayerForBlendArea) {
|
||||
const newValue = parseInt(blendAreaSlider.value, 10);
|
||||
selectedLayerForBlendArea.blendArea = newValue;
|
||||
log.info(`Blend Area changed to: ${newValue}% for layer: ${selectedLayerForBlendArea.id}`);
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
|
||||
blendAreaSlider.addEventListener('change', () => {
|
||||
this.canvas.saveState();
|
||||
});
|
||||
|
||||
blendAreaContainer.appendChild(blendAreaLabel);
|
||||
blendAreaContainer.appendChild(blendAreaSlider);
|
||||
content.appendChild(blendAreaContainer);
|
||||
|
||||
let isDragging = false;
|
||||
let dragOffset = { x: 0, y: 0 };
|
||||
|
||||
@@ -693,8 +797,17 @@ export class CanvasLayers {
|
||||
}
|
||||
|
||||
option.onclick = () => {
|
||||
content.querySelectorAll<HTMLInputElement>('input[type="range"]').forEach(s => s.style.display = 'none');
|
||||
content.querySelectorAll<HTMLDivElement>('.blend-mode-container div').forEach(d => d.style.backgroundColor = '');
|
||||
// Hide only the opacity sliders within other blend mode containers
|
||||
content.querySelectorAll<HTMLDivElement>('.blend-mode-container').forEach(c => {
|
||||
const opacitySlider = c.querySelector<HTMLInputElement>('input[type="range"]');
|
||||
if (opacitySlider) {
|
||||
opacitySlider.style.display = 'none';
|
||||
}
|
||||
const optionDiv = c.querySelector<HTMLDivElement>('div');
|
||||
if (optionDiv) {
|
||||
optionDiv.style.backgroundColor = '';
|
||||
}
|
||||
});
|
||||
|
||||
slider.style.display = 'block';
|
||||
option.style.backgroundColor = '#3a3a3a';
|
||||
|
||||
@@ -58,39 +58,29 @@ export class CanvasRenderer {
|
||||
|
||||
this.drawGrid(ctx);
|
||||
|
||||
// Use CanvasLayers to draw layers with proper blend area support
|
||||
this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
|
||||
|
||||
// Draw selection frames for selected layers
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => {
|
||||
if (!layer.image || !layer.visible) return;
|
||||
ctx.save();
|
||||
const currentTransform = ctx.getTransform();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.setTransform(currentTransform);
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||
|
||||
const scaleH = layer.flipH ? -1 : 1;
|
||||
const scaleV = layer.flipV ? -1 : 1;
|
||||
if (layer.flipH || layer.flipV) {
|
||||
ctx.scale(scaleH, scaleV);
|
||||
}
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(
|
||||
layer.image, -layer.width / 2, -layer.height / 2,
|
||||
layer.width,
|
||||
layer.height
|
||||
);
|
||||
if (layer.mask) {
|
||||
}
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
ctx.save();
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||
|
||||
const scaleH = layer.flipH ? -1 : 1;
|
||||
const scaleV = layer.flipV ? -1 : 1;
|
||||
if (layer.flipH || layer.flipV) {
|
||||
ctx.scale(scaleH, scaleV);
|
||||
}
|
||||
|
||||
this.drawSelectionFrame(ctx, layer);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
this.drawCanvasOutline(ctx);
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Layer {
|
||||
mask?: Float32Array;
|
||||
flipH?: boolean;
|
||||
flipV?: boolean;
|
||||
blendArea?: number;
|
||||
}
|
||||
|
||||
export interface ComfyNode {
|
||||
|
||||
244
src/utils/ImageAnalysis.ts
Normal file
244
src/utils/ImageAnalysis.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
|
||||
const log = createModuleLogger('ImageAnalysis');
|
||||
|
||||
/**
|
||||
* Creates a distance field mask based on the alpha channel of an image.
|
||||
* The mask will have gradients from the edges of visible pixels inward.
|
||||
* @param image - The source image to analyze
|
||||
* @param blendArea - The percentage (0-100) of the area to apply blending
|
||||
* @returns HTMLCanvasElement containing the distance field mask
|
||||
*/
|
||||
export function createDistanceFieldMask(image: HTMLImageElement, blendArea: number): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
if (!ctx) {
|
||||
log.error('Failed to create canvas context for distance field mask');
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// Draw the image to extract pixel data
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Check if image has transparency (any alpha < 255)
|
||||
let hasTransparency = false;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
if (data[i * 4 + 3] < 255) {
|
||||
hasTransparency = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let distanceField: Float32Array;
|
||||
let maxDistance: number;
|
||||
|
||||
if (hasTransparency) {
|
||||
// For images with transparency, use alpha-based distance transform
|
||||
const binaryMask = new Uint8Array(width * height);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
binaryMask[i] = data[i * 4 + 3] > 0 ? 1 : 0;
|
||||
}
|
||||
distanceField = calculateDistanceTransform(binaryMask, width, height);
|
||||
} else {
|
||||
// For opaque images, calculate distance from edges of the rectangle
|
||||
distanceField = calculateDistanceFromEdges(width, height);
|
||||
}
|
||||
|
||||
// Find the maximum distance to normalize
|
||||
maxDistance = 0;
|
||||
for (let i = 0; i < distanceField.length; i++) {
|
||||
if (distanceField[i] > maxDistance) {
|
||||
maxDistance = distanceField[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Create the gradient mask based on blendArea
|
||||
const maskData = ctx.createImageData(width, height);
|
||||
const threshold = maxDistance * (blendArea / 100);
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const distance = distanceField[i];
|
||||
const alpha = data[i * 4 + 3];
|
||||
|
||||
if (alpha === 0) {
|
||||
// Transparent pixels remain transparent
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = 0;
|
||||
} else if (distance <= threshold) {
|
||||
// Edge area - apply gradient alpha
|
||||
const gradientValue = distance / threshold;
|
||||
const alphaValue = Math.floor(gradientValue * 255);
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = alphaValue;
|
||||
} else {
|
||||
// Inner area - full alpha (no blending effect)
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear canvas and put the mask data
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.putImageData(maskData, 0, 0);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the Euclidean distance transform of a binary mask.
|
||||
* Uses a two-pass algorithm for efficiency.
|
||||
* @param binaryMask - Binary mask where 1 = inside, 0 = outside
|
||||
* @param width - Width of the mask
|
||||
* @param height - Height of the mask
|
||||
* @returns Float32Array containing distance values
|
||||
*/
|
||||
function calculateDistanceTransform(binaryMask: Uint8Array, width: number, height: number): Float32Array {
|
||||
const distances = new Float32Array(width * height);
|
||||
const infinity = width + height; // A value larger than any possible distance
|
||||
|
||||
// Initialize distances
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
distances[i] = binaryMask[i] === 1 ? infinity : 0;
|
||||
}
|
||||
|
||||
// Forward pass (top-left to bottom-right)
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
|
||||
// Check top neighbor
|
||||
if (y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + x] + 1);
|
||||
}
|
||||
|
||||
// Check left neighbor
|
||||
if (x > 0) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x - 1)] + 1);
|
||||
}
|
||||
|
||||
// Check top-left diagonal
|
||||
if (x > 0 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
// Check top-right diagonal
|
||||
if (x < width - 1 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backward pass (bottom-right to top-left)
|
||||
for (let y = height - 1; y >= 0; y--) {
|
||||
for (let x = width - 1; x >= 0; x--) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
|
||||
// Check bottom neighbor
|
||||
if (y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + x] + 1);
|
||||
}
|
||||
|
||||
// Check right neighbor
|
||||
if (x < width - 1) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x + 1)] + 1);
|
||||
}
|
||||
|
||||
// Check bottom-right diagonal
|
||||
if (x < width - 1 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
// Check bottom-left diagonal
|
||||
if (x > 0 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return distances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates distance from edges of a rectangle for opaque images.
|
||||
* @param width - Width of the rectangle
|
||||
* @param height - Height of the rectangle
|
||||
* @returns Float32Array containing distance values from edges
|
||||
*/
|
||||
function calculateDistanceFromEdges(width: number, height: number): Float32Array {
|
||||
const distances = new Float32Array(width * height);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
|
||||
// Calculate distance to nearest edge
|
||||
const distToLeft = x;
|
||||
const distToRight = width - 1 - x;
|
||||
const distToTop = y;
|
||||
const distToBottom = height - 1 - y;
|
||||
|
||||
// Minimum distance to any edge
|
||||
const minDistToEdge = Math.min(distToLeft, distToRight, distToTop, distToBottom);
|
||||
distances[idx] = minDistToEdge;
|
||||
}
|
||||
}
|
||||
|
||||
return distances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simple radial gradient mask (fallback for rectangular areas).
|
||||
* @param width - Width of the mask
|
||||
* @param height - Height of the mask
|
||||
* @param blendArea - The percentage (0-100) of the area to apply blending
|
||||
* @returns HTMLCanvasElement containing the radial gradient mask
|
||||
*/
|
||||
export function createRadialGradientMask(width: number, height: number, blendArea: number): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
log.error('Failed to create canvas context for radial gradient mask');
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
|
||||
const innerRadius = maxRadius * (1 - blendArea / 100);
|
||||
|
||||
// Create radial gradient
|
||||
const gradient = ctx.createRadialGradient(centerX, centerY, innerRadius, centerX, centerY, maxRadius);
|
||||
gradient.addColorStop(0, 'white');
|
||||
gradient.addColorStop(1, 'black');
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
Reference in New Issue
Block a user