diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index 8978f2a..36abba4 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -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) { diff --git a/js/CanvasRenderer.js b/js/CanvasRenderer.js index 451267a..5f4516d 100644 --- a/js/CanvasRenderer.js +++ b/js/CanvasRenderer.js @@ -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 diff --git a/js/utils/ImageAnalysis.js b/js/utils/ImageAnalysis.js new file mode 100644 index 0000000..98ef650 --- /dev/null +++ b/js/utils/ImageAnalysis.js @@ -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; +} diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts index 4f05f19..fcdee9e 100644 --- a/src/CanvasLayers.ts +++ b/src/CanvasLayers.ts @@ -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>; 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('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'; diff --git a/src/CanvasRenderer.ts b/src/CanvasRenderer.ts index d053542..c4154ea 100644 --- a/src/CanvasRenderer.ts +++ b/src/CanvasRenderer.ts @@ -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); diff --git a/src/types.ts b/src/types.ts index cac196e..a050a14 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,6 +20,7 @@ export interface Layer { mask?: Float32Array; flipH?: boolean; flipV?: boolean; + blendArea?: number; } export interface ComfyNode { diff --git a/src/utils/ImageAnalysis.ts b/src/utils/ImageAnalysis.ts new file mode 100644 index 0000000..d808f0e --- /dev/null +++ b/src/utils/ImageAnalysis.ts @@ -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; +}