import { saveImage } from "./db.js"; import { createModuleLogger } from "./utils/LoggerUtils.js"; import { generateUUID, generateUniqueFileName, createCanvas } from "./utils/CommonUtils.js"; import { withErrorHandling, createValidationError } from "./ErrorHandler.js"; import { showErrorNotification } from "./utils/NotificationUtils.js"; import { addStylesheet, getUrl } from "./utils/ResourceManager.js"; // @ts-ignore import { app } from "../../scripts/app.js"; // @ts-ignore import { ComfyApp } from "../../scripts/app.js"; import { ClipboardManager } from "./utils/ClipboardManager.js"; import { createDistanceFieldMaskSync } from "./utils/ImageAnalysis.js"; const log = createModuleLogger('CanvasLayers'); export class CanvasLayers { constructor(canvas) { this._canvasMaskCache = new Map(); this.blendMenuElement = null; this.blendMenuWorldX = 0; this.blendMenuWorldY = 0; // Cache for processed images with effects applied this.processedImageCache = new Map(); // Debouncing system for processed image creation this.processedImageDebounceTimers = new Map(); this.PROCESSED_IMAGE_DEBOUNCE_DELAY = 1000; // 1 second this.globalDebounceTimer = null; this.lastRenderTime = 0; this.layersAdjustingBlendArea = new Set(); this.layersTransformingCropBounds = new Set(); this.layersTransformingScale = new Set(); this.layersWheelScaling = new Set(); this.addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => { if (!image) { throw createValidationError("Image is required for layer creation"); } log.debug("Adding layer with image:", image, "with mode:", addMode, "targetArea:", targetArea); const imageId = generateUUID(); await saveImage(imageId, image.src); this.canvas.imageCache.set(imageId, image.src); let finalWidth = image.width; let finalHeight = image.height; let finalX, finalY; // Use the targetArea if provided, otherwise default to the current output area bounds const bounds = this.canvas.outputAreaBounds; const area = targetArea || { width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y }; if (addMode === 'fit') { const scale = Math.min(area.width / image.width, area.height / image.height); finalWidth = image.width * scale; finalHeight = image.height * scale; finalX = area.x + (area.width - finalWidth) / 2; finalY = area.y + (area.height - finalHeight) / 2; } else if (addMode === 'mouse') { finalX = this.canvas.lastMousePosition.x - finalWidth / 2; finalY = this.canvas.lastMousePosition.y - finalHeight / 2; } else { finalX = area.x + (area.width - finalWidth) / 2; finalY = area.y + (area.height - finalHeight) / 2; } // Find the highest zIndex among existing layers const maxZIndex = this.canvas.layers.length > 0 ? Math.max(...this.canvas.layers.map(l => l.zIndex)) : -1; const layer = { id: generateUUID(), image: image, imageId: imageId, name: 'Layer', x: finalX, y: finalY, width: finalWidth, height: finalHeight, originalWidth: image.width, originalHeight: image.height, rotation: 0, zIndex: maxZIndex + 1, // Always add new layer on top blendMode: 'normal', opacity: 1, visible: true, ...layerProps }; if (layer.mask) { const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height); if (tempCtx) { tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(layer.width, layer.height); if (maskCtx) { const maskImageData = maskCtx.createImageData(layer.width, layer.height); for (let i = 0; i < layer.mask.length; i++) { maskImageData.data[i * 4] = 255; maskImageData.data[i * 4 + 1] = 255; maskImageData.data[i * 4 + 2] = 255; maskImageData.data[i * 4 + 3] = layer.mask[i] * 255; } maskCtx.putImageData(maskImageData, 0, 0); tempCtx.globalCompositeOperation = 'destination-in'; tempCtx.drawImage(maskCanvas, 0, 0); const newImage = new Image(); newImage.src = tempCanvas.toDataURL(); layer.image = newImage; } } } this.canvas.layers.push(layer); this.canvas.updateSelection([layer]); this.canvas.render(); this.canvas.saveState(); if (this.canvas.canvasLayersPanel) { this.canvas.canvasLayersPanel.onLayersChanged(); } log.info("Layer added successfully"); return layer; }, 'CanvasLayers.addLayerWithImage'); this.currentCloseMenuListener = null; this.canvas = canvas; this.clipboardManager = new ClipboardManager(canvas); this.distanceFieldCache = new WeakMap(); this.processedImageCache = new Map(); this.processedImageDebounceTimers = new Map(); this.blendModes = [ { name: 'normal', label: 'Normal' }, { name: 'multiply', label: 'Multiply' }, { name: 'screen', label: 'Screen' }, { name: 'overlay', label: 'Overlay' }, { name: 'darken', label: 'Darken' }, { name: 'lighten', label: 'Lighten' }, { name: 'color-dodge', label: 'Color Dodge' }, { name: 'color-burn', label: 'Color Burn' }, { name: 'hard-light', label: 'Hard Light' }, { name: 'soft-light', label: 'Soft Light' }, { name: 'difference', label: 'Difference' }, { name: 'exclusion', label: 'Exclusion' } ]; this.selectedBlendMode = null; this.blendOpacity = 100; this.isAdjustingOpacity = false; this.internalClipboard = []; this.clipboardPreference = 'system'; // Load CSS for blend mode menu addStylesheet(getUrl('./css/blend_mode_menu.css')); } async copySelectedLayers() { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.internalClipboard = this.canvas.canvasSelection.selectedLayers.map((layer) => ({ ...layer })); log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`); const blob = await this.getFlattenedSelectionAsBlob(); if (!blob) { log.warn("Failed to create flattened selection blob"); return; } if (this.clipboardPreference === 'clipspace') { try { const dataURL = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(blob); }); const img = new Image(); img.onload = () => { if (!this.canvas.node.imgs) { this.canvas.node.imgs = []; } this.canvas.node.imgs[0] = img; if (ComfyApp.copyToClipspace) { ComfyApp.copyToClipspace(this.canvas.node); log.info("Flattened selection copied to ComfyUI Clipspace."); } else { log.warn("ComfyUI copyToClipspace not available"); } }; img.src = dataURL; } catch (error) { log.error("Failed to copy image to ComfyUI Clipspace:", error); try { const item = new ClipboardItem({ 'image/png': blob }); await navigator.clipboard.write([item]); log.info("Fallback: Flattened selection copied to system clipboard."); } catch (fallbackError) { log.error("Failed to copy to system clipboard as fallback:", fallbackError); } } } else { try { const item = new ClipboardItem({ 'image/png': blob }); await navigator.clipboard.write([item]); log.info("Flattened selection copied to system clipboard."); } catch (error) { log.error("Failed to copy image to system clipboard:", error); } } } pasteLayers() { if (this.internalClipboard.length === 0) return; this.canvas.saveState(); const newLayers = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; this.internalClipboard.forEach((layer) => { minX = Math.min(minX, layer.x); minY = Math.min(minY, layer.y); maxX = Math.max(maxX, layer.x + layer.width); maxY = Math.max(maxY, layer.y + layer.height); }); const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; const { x: mouseX, y: mouseY } = this.canvas.lastMousePosition; const offsetX = mouseX - centerX; const offsetY = mouseY - centerY; // Find the highest zIndex among existing layers const maxZIndex = this.canvas.layers.length > 0 ? Math.max(...this.canvas.layers.map(l => l.zIndex)) : -1; this.internalClipboard.forEach((clipboardLayer, index) => { const newLayer = { ...clipboardLayer, x: clipboardLayer.x + offsetX, y: clipboardLayer.y + offsetY, zIndex: maxZIndex + 1 + index // Ensure pasted layers maintain their relative order }; this.canvas.layers.push(newLayer); newLayers.push(newLayer); }); this.canvas.updateSelection(newLayers); this.canvas.render(); if (this.canvas.canvasLayersPanel) { this.canvas.canvasLayersPanel.onLayersChanged(); } log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`); } async handlePaste(addMode = 'mouse') { try { log.info(`Paste operation started with preference: ${this.clipboardPreference}`); await this.clipboardManager.handlePaste(addMode, this.clipboardPreference); } catch (err) { log.error("Paste operation failed:", err); } } async addLayer(image) { return this.addLayerWithImage(image); } moveLayers(layersToMove, options = {}) { if (!layersToMove || layersToMove.length === 0) return; let finalLayers; if (options.direction) { const allLayers = [...this.canvas.layers]; const selectedIndices = new Set(layersToMove.map((l) => allLayers.indexOf(l))); if (options.direction === 'up') { const sorted = Array.from(selectedIndices).sort((a, b) => b - a); sorted.forEach((index) => { const targetIndex = index + 1; if (targetIndex < allLayers.length && !selectedIndices.has(targetIndex)) { [allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]]; } }); } else if (options.direction === 'down') { const sorted = Array.from(selectedIndices).sort((a, b) => a - b); sorted.forEach((index) => { const targetIndex = index - 1; if (targetIndex >= 0 && !selectedIndices.has(targetIndex)) { [allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]]; } }); } finalLayers = allLayers; } else if (options.toIndex !== undefined) { const displayedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const reorderedFinal = []; let inserted = false; for (let i = 0; i < displayedLayers.length; i++) { if (i === options.toIndex) { reorderedFinal.push(...layersToMove); inserted = true; } const currentLayer = displayedLayers[i]; if (!layersToMove.includes(currentLayer)) { reorderedFinal.push(currentLayer); } } if (!inserted) { reorderedFinal.push(...layersToMove); } finalLayers = reorderedFinal; } else { log.warn("Invalid options for moveLayers", options); return; } const totalLayers = finalLayers.length; finalLayers.forEach((layer, index) => { const zIndex = (options.toIndex !== undefined) ? (totalLayers - 1 - index) : index; layer.zIndex = zIndex; }); this.canvas.layers = finalLayers; this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex); if (this.canvas.canvasLayersPanel) { this.canvas.canvasLayersPanel.onLayersChanged(); } this.canvas.render(); this.canvas.requestSaveState(); log.info(`Moved ${layersToMove.length} layer(s).`); } moveLayerUp() { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'up' }); } moveLayerDown() { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'down' }); } resizeLayer(scale) { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.canvas.canvasSelection.selectedLayers.forEach((layer) => { layer.width *= scale; layer.height *= scale; // Invalidate processed image cache when layer dimensions change this.invalidateProcessedImageCache(layer.id); // Handle wheel scaling end for layers with blend area this.handleWheelScalingEnd(layer); }); this.canvas.render(); this.canvas.requestSaveState(); } rotateLayer(angle) { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.canvas.canvasSelection.selectedLayers.forEach((layer) => { layer.rotation += angle; }); this.canvas.render(); this.canvas.requestSaveState(); } getLayerAtPosition(worldX, worldY) { // Always sort by zIndex so topmost is checked first this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex); for (let i = this.canvas.layers.length - 1; i >= 0; i--) { const layer = this.canvas.layers[i]; // Skip invisible layers if (!layer.visible) continue; const centerX = layer.x + layer.width / 2; const centerY = layer.y + layer.height / 2; const dx = worldX - centerX; const dy = worldY - centerY; const rad = -layer.rotation * Math.PI / 180; const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad); const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad); if (Math.abs(rotatedX) <= layer.width / 2 && Math.abs(rotatedY) <= layer.height / 2) { return { layer: layer, localX: rotatedX + layer.width / 2, localY: rotatedY + layer.height / 2 }; } } return null; } _drawLayer(ctx, layer, options = {}) { if (!layer.image) return; const { offsetX = 0, offsetY = 0 } = options; ctx.save(); const centerX = layer.x + layer.width / 2 - offsetX; const centerY = layer.y + layer.height / 2 - offsetY; 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'; const blendArea = layer.blendArea ?? 0; const needsBlendAreaEffect = blendArea > 0; // Check if we should render blend area live only in specific cases: // 1. When user is actively resizing in crop mode (transforming crop bounds) - only for the specific layer being transformed // 2. When user is actively resizing in transform mode (scaling layer) - only for the specific layer being transformed // 3. When blend area slider is being adjusted - only for the layer that has the menu open // 4. When layer is in the transforming crop bounds set (continues live rendering until cache is ready) // 5. When layer is in the transforming scale set (continues live rendering until cache is ready) const isTransformingCropBounds = this.canvas.canvasInteractions?.interaction?.mode === 'resizing' && this.canvas.canvasInteractions?.interaction?.transformingLayer?.id === layer.id && layer.cropMode; // Check if user is actively scaling this layer in transform mode (not crop mode) const isTransformingScale = this.canvas.canvasInteractions?.interaction?.mode === 'resizing' && this.canvas.canvasInteractions?.interaction?.transformingLayer?.id === layer.id && !layer.cropMode; // Check if this specific layer is the one being adjusted in blend area slider const isThisLayerBeingAdjusted = this.layersAdjustingBlendArea.has(layer.id); // Check if this layer is in the transforming crop bounds set (continues live rendering until cache is ready) const isTransformingCropBoundsSet = this.layersTransformingCropBounds.has(layer.id); // Check if this layer is in the transforming scale set (continues live rendering until cache is ready) const isTransformingScaleSet = this.layersTransformingScale.has(layer.id); // Check if this layer is being scaled by wheel or buttons (continues live rendering until cache is ready) const isWheelScaling = this.layersWheelScaling.has(layer.id); const shouldRenderLive = isTransformingCropBounds || isTransformingScale || isThisLayerBeingAdjusted || isTransformingCropBoundsSet || isTransformingScaleSet || isWheelScaling; // Check if we should use cached processed image or render live const processedImage = this.getProcessedImage(layer); // For scaling operations, try to find the BEST matching cache for this layer let bestMatchingCache = null; if (isTransformingScale || isTransformingScaleSet || isWheelScaling) { // Look for cache entries that match the current layer state as closely as possible const currentCacheKey = this.getProcessedImageCacheKey(layer); const currentBlendArea = layer.blendArea ?? 0; const currentCropKey = layer.cropBounds ? `${layer.cropBounds.x},${layer.cropBounds.y},${layer.cropBounds.width},${layer.cropBounds.height}` : 'nocrop'; // Score each cache entry to find the best match let bestScore = -1; for (const [key, image] of this.processedImageCache.entries()) { if (key.startsWith(layer.id + '_')) { let score = 0; // Extract blend area and crop info from cache key const keyParts = key.split('_'); if (keyParts.length >= 3) { const cacheBlendArea = parseInt(keyParts[1]); const cacheCropKey = keyParts[2]; // Score based on blend area match (higher priority) if (cacheBlendArea === currentBlendArea) { score += 100; } else { score -= Math.abs(cacheBlendArea - currentBlendArea); } // Score based on crop match (high priority) if (cacheCropKey === currentCropKey) { score += 200; } else { // Penalize mismatched crop states heavily score -= 150; } // Small bonus for exact match if (key === currentCacheKey) { score += 50; } } if (score > bestScore) { bestScore = score; bestMatchingCache = image; log.debug(`Better cache found for layer ${layer.id}: ${key} (score: ${score})`); } } } if (bestMatchingCache) { log.debug(`Using best matching cache for layer ${layer.id} during scaling`); } } if (processedImage && !shouldRenderLive) { // Use cached processed image for all cases except specific live rendering scenarios ctx.globalCompositeOperation = layer.blendMode || 'normal'; ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; ctx.drawImage(processedImage, -layer.width / 2, -layer.height / 2, layer.width, layer.height); } else if (bestMatchingCache && (isTransformingScale || isTransformingScaleSet || isWheelScaling)) { // During scaling operations: use the BEST matching processed image (more efficient) // This ensures we always use the most appropriate blend area image during scaling ctx.globalCompositeOperation = layer.blendMode || 'normal'; ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; ctx.drawImage(bestMatchingCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height); } else if (needsBlendAreaEffect && shouldRenderLive && !isWheelScaling) { // Render blend area live only when transforming crop bounds or adjusting blend area slider // BUT NOT during wheel scaling - that should use cached image this._drawLayerWithLiveBlendArea(ctx, layer); } else { // Normal drawing without blend area effect this._drawLayerImage(ctx, layer); } ctx.restore(); } /** * Zunifikowana funkcja do rysowania obrazu warstwy z crop * @param ctx Canvas context * @param layer Warstwa do narysowania * @param offsetX Przesunięcie X względem środka warstwy (domyślnie -width/2) * @param offsetY Przesunięcie Y względem środka warstwy (domyślnie -height/2) */ drawLayerImageWithCrop(ctx, layer, offsetX = -layer.width / 2, offsetY = -layer.height / 2) { // Use cropBounds if they exist, otherwise use the full image dimensions as the source const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight }; if (!layer.originalWidth || !layer.originalHeight) { // Fallback for older layers without original dimensions or if data is missing ctx.drawImage(layer.image, offsetX, offsetY, layer.width, layer.height); return; } // Calculate the on-screen scale of the layer's transform frame const layerScaleX = layer.width / layer.originalWidth; const layerScaleY = layer.height / layer.originalHeight; // Calculate the on-screen size of the cropped portion const dWidth = s.width * layerScaleX; const dHeight = s.height * layerScaleY; // Calculate the on-screen position of the top-left of the cropped portion const dX = offsetX + (s.x * layerScaleX); const dY = offsetY + (s.y * layerScaleY); ctx.drawImage(layer.image, s.x, s.y, s.width, s.height, // source rect (from original image) dX, dY, dWidth, dHeight // destination rect (scaled and positioned) ); } _drawLayerImage(ctx, layer) { ctx.globalCompositeOperation = layer.blendMode || 'normal'; ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; this.drawLayerImageWithCrop(ctx, layer); } /** * Zunifikowana funkcja do tworzenia maski blend area dla warstwy * @param layer Warstwa dla której tworzymy maskę * @returns Obiekt zawierający maskę i jej wymiary lub null */ createBlendAreaMask(layer) { const blendArea = layer.blendArea ?? 0; if (layer.cropBounds && layer.originalWidth && layer.originalHeight) { // Create a cropped canvas const s = layer.cropBounds; const { canvas: cropCanvas, ctx: cropCtx } = createCanvas(s.width, s.height); if (cropCtx) { cropCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, 0, 0, s.width, s.height); // Generate distance field mask for the cropped region const maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea); if (maskCanvas) { return { maskCanvas, maskWidth: s.width, maskHeight: s.height }; } } } else { // No crop, use full image const maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea); if (maskCanvas) { return { maskCanvas, maskWidth: layer.originalWidth || layer.width, maskHeight: layer.originalHeight || layer.height }; } } return null; } /** * Zunifikowana funkcja do rysowania warstwy z blend area na canvas * @param ctx Canvas context * @param layer Warstwa do narysowania * @param offsetX Przesunięcie X (domyślnie -width/2) * @param offsetY Przesunięcie Y (domyślnie -height/2) */ drawLayerWithBlendArea(ctx, layer, offsetX = -layer.width / 2, offsetY = -layer.height / 2) { const maskInfo = this.createBlendAreaMask(layer); if (maskInfo) { const { maskCanvas, maskWidth, maskHeight } = maskInfo; const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight }; if (!layer.originalWidth || !layer.originalHeight) { // Fallback - just draw the image normally ctx.drawImage(layer.image, offsetX, offsetY, layer.width, layer.height); } else { const layerScaleX = layer.width / layer.originalWidth; const layerScaleY = layer.height / layer.originalHeight; const dWidth = s.width * layerScaleX; const dHeight = s.height * layerScaleY; const dX = offsetX + (s.x * layerScaleX); const dY = offsetY + (s.y * layerScaleY); // Draw the image ctx.drawImage(layer.image, s.x, s.y, s.width, s.height, dX, dY, dWidth, dHeight); // Apply the distance field mask ctx.globalCompositeOperation = 'destination-in'; ctx.drawImage(maskCanvas, 0, 0, maskWidth, maskHeight, dX, dY, dWidth, dHeight); } } else { // Fallback - just draw the image normally this.drawLayerImageWithCrop(ctx, layer, offsetX, offsetY); } } /** * Draw layer with live blend area effect during user activity (original behavior) */ _drawLayerWithLiveBlendArea(ctx, layer) { // Create a temporary canvas for the masked layer const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height); if (tempCtx) { // Draw the layer with blend area to temp canvas this.drawLayerWithBlendArea(tempCtx, layer, 0, 0); // Draw the result with blend mode and opacity 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 this._drawLayerImage(ctx, layer); } } /** * Generate a cache key for processed images based on layer properties */ getProcessedImageCacheKey(layer) { const blendArea = layer.blendArea ?? 0; const cropKey = layer.cropBounds ? `${layer.cropBounds.x},${layer.cropBounds.y},${layer.cropBounds.width},${layer.cropBounds.height}` : 'nocrop'; return `${layer.id}_${blendArea}_${cropKey}_${layer.width}_${layer.height}`; } /** * Get processed image with all effects applied (blend area, crop, etc.) * Uses live rendering for layers being actively adjusted, debounced processing for others */ getProcessedImage(layer) { const blendArea = layer.blendArea ?? 0; const needsBlendAreaEffect = blendArea > 0; const needsCropEffect = layer.cropBounds && layer.originalWidth && layer.originalHeight; // If no effects needed, return null to use normal drawing if (!needsBlendAreaEffect && !needsCropEffect) { return null; } // If this layer is being actively adjusted (blend area slider), don't use cache if (this.layersAdjustingBlendArea.has(layer.id)) { return null; // Force live rendering } // If this layer is being scaled (wheel/buttons), don't schedule new cache creation if (this.layersWheelScaling.has(layer.id)) { const cacheKey = this.getProcessedImageCacheKey(layer); // Only return existing cache, don't create new one if (this.processedImageCache.has(cacheKey)) { log.debug(`Using cached processed image for layer ${layer.id} during wheel scaling`); return this.processedImageCache.get(cacheKey) || null; } // No cache available and we're scaling - return null to use normal drawing return null; } const cacheKey = this.getProcessedImageCacheKey(layer); // Check if we have cached processed image if (this.processedImageCache.has(cacheKey)) { log.debug(`Using cached processed image for layer ${layer.id}`); return this.processedImageCache.get(cacheKey) || null; } // Use debounced processing - schedule creation but don't create immediately this.scheduleProcessedImageCreation(layer, cacheKey); return null; // Use original image for now until processed image is ready } /** * Schedule processed image creation after debounce delay */ scheduleProcessedImageCreation(layer, cacheKey) { // Clear existing timer for this layer const existingTimer = this.processedImageDebounceTimers.get(layer.id); if (existingTimer) { clearTimeout(existingTimer); } // Schedule new timer const timer = window.setTimeout(() => { log.info(`Creating debounced processed image for layer ${layer.id}`); try { const processedImage = this.createProcessedImage(layer); if (processedImage) { this.processedImageCache.set(cacheKey, processedImage); log.debug(`Cached debounced processed image for layer ${layer.id}`); // Trigger re-render to show the processed image this.canvas.render(); } } catch (error) { log.error('Failed to create debounced processed image:', error); } // Clean up timer this.processedImageDebounceTimers.delete(layer.id); }, this.PROCESSED_IMAGE_DEBOUNCE_DELAY); this.processedImageDebounceTimers.set(layer.id, timer); } /** * Update last render time to track activity for debouncing */ updateLastRenderTime() { this.lastRenderTime = Date.now(); log.debug(`Updated last render time for debouncing: ${this.lastRenderTime}`); } /** * Process all pending images immediately when user stops interacting */ processPendingImages() { // Clear all pending timers and process immediately for (const [layerId, timer] of this.processedImageDebounceTimers.entries()) { clearTimeout(timer); // Find the layer and process it const layer = this.canvas.layers.find(l => l.id === layerId); if (layer) { const cacheKey = this.getProcessedImageCacheKey(layer); if (!this.processedImageCache.has(cacheKey)) { try { const processedImage = this.createProcessedImage(layer); if (processedImage) { this.processedImageCache.set(cacheKey, processedImage); log.debug(`Processed pending image for layer ${layer.id}`); } } catch (error) { log.error(`Failed to process pending image for layer ${layer.id}:`, error); } } } } this.processedImageDebounceTimers.clear(); // Trigger re-render to show all processed images if (this.processedImageDebounceTimers.size > 0) { this.canvas.render(); } } /** * Create a new processed image with all effects applied */ createProcessedImage(layer) { const blendArea = layer.blendArea ?? 0; const needsBlendAreaEffect = blendArea > 0; // Create a canvas for the processed image const { canvas: processedCanvas, ctx: processedCtx } = createCanvas(layer.width, layer.height); if (!processedCtx) return null; if (needsBlendAreaEffect) { // Use the unified blend area drawing function this.drawLayerWithBlendArea(processedCtx, layer, 0, 0); } else { // Just apply crop effect without blend area this.drawLayerImageWithCrop(processedCtx, layer, 0, 0); } // Convert canvas to image const processedImage = new Image(); processedImage.src = processedCanvas.toDataURL(); return processedImage; } /** * Helper method to draw layer image to a specific canvas context (position 0,0) * Uses the unified drawLayerImageWithCrop function */ _drawLayerImageToCanvas(ctx, layer) { this.drawLayerImageWithCrop(ctx, layer, 0, 0); } /** * Invalidate processed image cache for a specific layer */ invalidateProcessedImageCache(layerId) { const keysToDelete = []; for (const key of this.processedImageCache.keys()) { if (key.startsWith(`${layerId}_`)) { keysToDelete.push(key); } } keysToDelete.forEach(key => { this.processedImageCache.delete(key); log.debug(`Invalidated processed image cache for key: ${key}`); }); // Also clear any pending timers for this layer const existingTimer = this.processedImageDebounceTimers.get(layerId); if (existingTimer) { clearTimeout(existingTimer); this.processedImageDebounceTimers.delete(layerId); log.debug(`Cleared pending timer for layer ${layerId}`); } } /** * Clear all processed image cache */ clearProcessedImageCache() { this.processedImageCache.clear(); // Clear all pending timers for (const timer of this.processedImageDebounceTimers.values()) { clearTimeout(timer); } this.processedImageDebounceTimers.clear(); log.info('Cleared all processed image cache and pending timers'); } /** * Zunifikowana funkcja do obsługi transformacji końcowych * @param layer Warstwa do przetworzenia * @param transformType Typ transformacji (crop, scale, wheel) * @param delay Opóźnienie w ms (domyślnie 0) */ handleTransformEnd(layer, transformType, delay = 0) { if (!layer.blendArea) return; const layerId = layer.id; const cacheKey = this.getProcessedImageCacheKey(layer); // Add to appropriate transforming set to continue live rendering let transformingSet; let transformName; switch (transformType) { case 'crop': transformingSet = this.layersTransformingCropBounds; transformName = 'crop bounds'; break; case 'scale': transformingSet = this.layersTransformingScale; transformName = 'scale'; break; case 'wheel': transformingSet = this.layersWheelScaling; transformName = 'wheel'; break; } transformingSet.add(layerId); // Create processed image asynchronously with optional delay const executeTransform = () => { try { const processedImage = this.createProcessedImage(layer); if (processedImage) { this.processedImageCache.set(cacheKey, processedImage); log.debug(`Cached processed image for layer ${layerId} after ${transformName} transform`); // Only now remove from live rendering set and trigger re-render transformingSet.delete(layerId); this.canvas.render(); } } catch (error) { log.error(`Failed to create processed image after ${transformName} transform:`, error); // Fallback: remove from live rendering even if cache creation failed transformingSet.delete(layerId); } }; if (delay > 0) { // For wheel scaling, use debounced approach const timerKey = `${layerId}_${transformType}scaling`; const existingTimer = this.processedImageDebounceTimers.get(timerKey); if (existingTimer) { clearTimeout(existingTimer); } const timer = window.setTimeout(() => { log.debug(`Creating new cache for layer ${layerId} after ${transformName} scaling stopped`); executeTransform(); this.processedImageDebounceTimers.delete(timerKey); }, delay); this.processedImageDebounceTimers.set(timerKey, timer); } else { // For crop and scale, use immediate async approach setTimeout(executeTransform, 0); } } /** * Handle end of crop bounds transformation - create cache asynchronously but keep live rendering until ready */ handleCropBoundsTransformEnd(layer) { if (!layer.cropMode || !layer.blendArea) return; this.handleTransformEnd(layer, 'crop', 0); } /** * Handle end of scale transformation - create cache asynchronously but keep live rendering until ready */ handleScaleTransformEnd(layer) { if (!layer.blendArea) return; this.handleTransformEnd(layer, 'scale', 0); } /** * Handle end of wheel/button scaling - use debounced cache creation */ handleWheelScalingEnd(layer) { if (!layer.blendArea) return; this.handleTransformEnd(layer, 'wheel', 500); } getDistanceFieldMaskSync(imageOrCanvas, blendArea) { // Use a WeakMap for images, and a Map for canvases (since canvases are not always stable references) let cacheKey = imageOrCanvas; if (imageOrCanvas instanceof HTMLCanvasElement) { // For canvases, use a Map on this instance (not WeakMap) if (!this._canvasMaskCache) this._canvasMaskCache = new Map(); let canvasCache = this._canvasMaskCache.get(imageOrCanvas); if (!canvasCache) { canvasCache = new Map(); this._canvasMaskCache.set(imageOrCanvas, canvasCache); } if (canvasCache.has(blendArea)) { log.info(`Using cached distance field mask for blendArea: ${blendArea}% (canvas)`); return canvasCache.get(blendArea) || null; } try { log.info(`Creating distance field mask for blendArea: ${blendArea}% (canvas)`); const maskCanvas = createDistanceFieldMaskSync(imageOrCanvas, blendArea); log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`); canvasCache.set(blendArea, maskCanvas); return maskCanvas; } catch (error) { log.error('Failed to create distance field mask (canvas):', error); return null; } } else { // For images, use the original WeakMap cache let imageCache = this.distanceFieldCache.get(imageOrCanvas); if (!imageCache) { imageCache = new Map(); this.distanceFieldCache.set(imageOrCanvas, imageCache); } let maskCanvas = imageCache.get(blendArea); if (!maskCanvas) { try { log.info(`Creating distance field mask for blendArea: ${blendArea}%`); maskCanvas = createDistanceFieldMaskSync(imageOrCanvas, 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 => { if (layer.visible) { this._drawLayer(ctx, layer, options); } }); } drawLayersToContext(ctx, layers, options = {}) { this._drawLayers(ctx, layers, options); } async mirrorHorizontal() { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.canvas.canvasSelection.selectedLayers.forEach((layer) => { layer.flipH = !layer.flipH; }); this.canvas.render(); this.canvas.requestSaveState(); } async mirrorVertical() { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.canvas.canvasSelection.selectedLayers.forEach((layer) => { layer.flipV = !layer.flipV; }); this.canvas.render(); this.canvas.requestSaveState(); } async getLayerImageData(layer) { try { const width = layer.originalWidth || layer.width; const height = layer.originalHeight || layer.height; const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(width, height, '2d', { willReadFrequently: true }); if (!tempCtx) throw new Error("Could not create canvas context"); // Use original image directly to ensure full quality tempCtx.drawImage(layer.image, 0, 0, width, height); const dataUrl = tempCanvas.toDataURL('image/png'); if (!dataUrl.startsWith('data:image/png;base64,')) { throw new Error("Invalid image data format"); } return dataUrl; } catch (error) { log.error("Error getting layer image data:", error); throw error; } } updateOutputAreaSize(width, height, saveHistory = true) { if (saveHistory) { this.canvas.saveState(); } this.canvas.width = width; this.canvas.height = height; this.canvas.maskTool.resize(width, height); this.canvas.canvas.width = width; this.canvas.canvas.height = height; this.canvas.render(); if (saveHistory) { this.canvas.canvasState.saveStateToDB(); } } /** * Ustawia nowy rozmiar output area względem środka, resetuje rozszerzenia. */ setOutputAreaSize(width, height) { // Reset rozszerzeń this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; this.canvas.outputAreaExtensionEnabled = false; this.canvas.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; // Oblicz środek obecnego output area const prevBounds = this.canvas.outputAreaBounds; const centerX = prevBounds.x + prevBounds.width / 2; const centerY = prevBounds.y + prevBounds.height / 2; // Nowa pozycja lewego górnego rogu, by środek pozostał w miejscu const newX = centerX - width / 2; const newY = centerY - height / 2; // Ustaw nowy rozmiar bazowy i pozycję this.canvas.originalCanvasSize = { width, height }; this.canvas.originalOutputAreaPosition = { x: newX, y: newY }; // Ustaw outputAreaBounds na nowy rozmiar i pozycję this.canvas.outputAreaBounds = { x: newX, y: newY, width, height }; // Zaktualizuj rozmiar przez istniejącą metodę (ustawia maskę, itp.) this.updateOutputAreaSize(width, height, true); this.canvas.render(); this.canvas.saveState(); } getHandles(layer) { const layerCenterX = layer.x + layer.width / 2; const layerCenterY = layer.y + layer.height / 2; const rad = layer.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); let handleCenterX, handleCenterY, halfW, halfH; if (layer.cropMode && layer.cropBounds && layer.originalWidth) { // CROP MODE: Handles are relative to the cropped area const layerScaleX = layer.width / layer.originalWidth; const layerScaleY = layer.height / layer.originalHeight; const cropRectW = layer.cropBounds.width * layerScaleX; const cropRectH = layer.cropBounds.height * layerScaleY; // Effective crop bounds start position, accounting for flips. const effectiveCropX = layer.flipH ? layer.originalWidth - (layer.cropBounds.x + layer.cropBounds.width) : layer.cropBounds.x; const effectiveCropY = layer.flipV ? layer.originalHeight - (layer.cropBounds.y + layer.cropBounds.height) : layer.cropBounds.y; // Center of the CROP rectangle in the layer's local, un-rotated space const cropCenterX_local = (-layer.width / 2) + ((effectiveCropX + layer.cropBounds.width / 2) * layerScaleX); const cropCenterY_local = (-layer.height / 2) + ((effectiveCropY + layer.cropBounds.height / 2) * layerScaleY); // Rotate this local center to find the world-space center of the crop rect handleCenterX = layerCenterX + (cropCenterX_local * cos - cropCenterY_local * sin); handleCenterY = layerCenterY + (cropCenterX_local * sin + cropCenterY_local * cos); halfW = cropRectW / 2; halfH = cropRectH / 2; } else { // TRANSFORM MODE: Handles are relative to the full layer transform frame handleCenterX = layerCenterX; handleCenterY = layerCenterY; halfW = layer.width / 2; halfH = layer.height / 2; } const localHandles = { 'n': { x: 0, y: -halfH }, 'ne': { x: halfW, y: -halfH }, 'e': { x: halfW, y: 0 }, 'se': { x: halfW, y: halfH }, 's': { x: 0, y: halfH }, 'sw': { x: -halfW, y: halfH }, 'w': { x: -halfW, y: 0 }, 'nw': { x: -halfW, y: -halfH }, 'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom } }; const worldHandles = {}; for (const key in localHandles) { const p = localHandles[key]; worldHandles[key] = { x: handleCenterX + (p.x * cos - p.y * sin), y: handleCenterY + (p.x * sin + p.y * cos) }; } return worldHandles; } getHandleAtPosition(worldX, worldY) { if (this.canvas.canvasSelection.selectedLayers.length === 0) return null; const handleRadius = 8 / this.canvas.viewport.zoom; for (let i = this.canvas.canvasSelection.selectedLayers.length - 1; i >= 0; i--) { const layer = this.canvas.canvasSelection.selectedLayers[i]; const handles = this.getHandles(layer); for (const key in handles) { const handlePos = handles[key]; const dx = worldX - handlePos.x; const dy = worldY - handlePos.y; if (dx * dx + dy * dy <= handleRadius * handleRadius) { return { layer: layer, handle: key }; } } } return null; } updateBlendModeMenuPosition() { if (!this.blendMenuElement) return; const screenX = (this.blendMenuWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; const screenY = (this.blendMenuWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom; this.blendMenuElement.style.transform = `translate(${screenX}px, ${screenY}px)`; } showBlendModeMenu(worldX, worldY) { if (this.canvas.canvasSelection.selectedLayers.length === 0) { return; } // Find which selected layer is at the click position (topmost visible layer at that position) let selectedLayer = null; const visibleSelectedLayers = this.canvas.canvasSelection.selectedLayers.filter((layer) => layer.visible); if (visibleSelectedLayers.length === 0) { return; } // Sort by zIndex descending and find the first one that contains the click point const sortedLayers = visibleSelectedLayers.sort((a, b) => b.zIndex - a.zIndex); for (const layer of sortedLayers) { const centerX = layer.x + layer.width / 2; const centerY = layer.y + layer.height / 2; // Transform click point to layer's local coordinates const dx = worldX - centerX; const dy = worldY - centerY; const rad = -layer.rotation * Math.PI / 180; const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad); const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad); const withinX = Math.abs(rotatedX) <= layer.width / 2; const withinY = Math.abs(rotatedY) <= layer.height / 2; // Check if click is within layer bounds if (withinX && withinY) { selectedLayer = layer; break; } } // If no layer found at click position, fall back to topmost visible selected layer if (!selectedLayer) { selectedLayer = sortedLayers[0]; } // At this point selectedLayer is guaranteed to be non-null if (!selectedLayer) { return; } // Remove any existing event listener first if (this.currentCloseMenuListener) { document.removeEventListener('mousedown', this.currentCloseMenuListener); this.currentCloseMenuListener = null; } this.closeBlendModeMenu(); // Calculate position in WORLD coordinates (top-right of viewport) const viewLeft = this.canvas.viewport.x; const viewTop = this.canvas.viewport.y; const viewWidth = this.canvas.canvas.width / this.canvas.viewport.zoom; // Position near top-right corner this.blendMenuWorldX = viewLeft + viewWidth - (250 / this.canvas.viewport.zoom); // 250px from right edge this.blendMenuWorldY = viewTop + (10 / this.canvas.viewport.zoom); // 10px from top edge const menu = document.createElement('div'); this.blendMenuElement = menu; menu.id = 'blend-mode-menu'; const titleBar = document.createElement('div'); titleBar.className = 'blend-menu-title-bar'; const titleText = document.createElement('span'); titleText.textContent = `Blend Mode: ${selectedLayer.name}`; titleText.className = 'blend-menu-title-text'; const closeButton = document.createElement('button'); closeButton.textContent = '×'; closeButton.className = 'blend-menu-close-button'; closeButton.onclick = (e) => { e.stopPropagation(); this.closeBlendModeMenu(); }; titleBar.appendChild(titleText); titleBar.appendChild(closeButton); const content = document.createElement('div'); content.className = 'blend-menu-content'; menu.appendChild(titleBar); menu.appendChild(content); const blendAreaContainer = document.createElement('div'); blendAreaContainer.className = 'blend-area-container'; const blendAreaLabel = document.createElement('label'); blendAreaLabel.textContent = 'Blend Area'; blendAreaLabel.className = 'blend-area-label'; const blendAreaSlider = document.createElement('input'); blendAreaSlider.type = 'range'; blendAreaSlider.min = '0'; blendAreaSlider.max = '100'; blendAreaSlider.className = 'blend-area-slider'; blendAreaSlider.value = selectedLayer?.blendArea?.toString() ?? '0'; blendAreaSlider.oninput = () => { if (selectedLayer) { const newValue = parseInt(blendAreaSlider.value, 10); selectedLayer.blendArea = newValue; // Set flag to enable live blend area rendering for this specific layer this.layersAdjustingBlendArea.add(selectedLayer.id); // Invalidate processed image cache when blend area changes this.invalidateProcessedImageCache(selectedLayer.id); this.canvas.render(); } }; blendAreaSlider.addEventListener('change', () => { // When user stops adjusting, create cache asynchronously but keep live rendering until cache is ready if (selectedLayer) { const layerId = selectedLayer.id; const cacheKey = this.getProcessedImageCacheKey(selectedLayer); // Create processed image asynchronously setTimeout(() => { try { const processedImage = this.createProcessedImage(selectedLayer); if (processedImage) { this.processedImageCache.set(cacheKey, processedImage); log.debug(`Cached processed image for layer ${layerId} after slider change`); // Only now remove from live rendering set and trigger re-render this.layersAdjustingBlendArea.delete(layerId); this.canvas.render(); } } catch (error) { log.error('Failed to create processed image after slider change:', error); // Fallback: remove from live rendering even if cache creation failed this.layersAdjustingBlendArea.delete(layerId); } }, 0); // Use setTimeout to make it asynchronous } this.canvas.saveState(); }); blendAreaContainer.appendChild(blendAreaLabel); blendAreaContainer.appendChild(blendAreaSlider); content.appendChild(blendAreaContainer); let isDragging = false; let dragOffset = { x: 0, y: 0 }; // Drag logic needs to update world coordinates, not screen coordinates const handleMouseMove = (e) => { if (isDragging) { const dx = e.movementX / this.canvas.viewport.zoom; const dy = e.movementY / this.canvas.viewport.zoom; this.blendMenuWorldX += dx; this.blendMenuWorldY += dy; this.updateBlendModeMenuPosition(); } }; const handleMouseUp = () => { if (isDragging) { isDragging = false; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); } }; titleBar.addEventListener('mousedown', (e) => { isDragging = true; e.preventDefault(); e.stopPropagation(); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }); this.blendModes.forEach((mode) => { const container = document.createElement('div'); container.className = 'blend-mode-container'; const option = document.createElement('div'); option.className = 'blend-mode-option'; option.textContent = `${mode.label} (${mode.name})`; const slider = document.createElement('input'); slider.type = 'range'; slider.min = '0'; slider.max = '100'; slider.className = 'blend-opacity-slider'; const selectedLayer = this.canvas.canvasSelection.selectedLayers[0]; slider.value = selectedLayer ? String(Math.round(selectedLayer.opacity * 100)) : '100'; if (selectedLayer && selectedLayer.blendMode === mode.name) { container.classList.add('active'); option.classList.add('active'); } option.onclick = () => { // Re-check selected layer at the time of click const currentSelectedLayer = this.canvas.canvasSelection.selectedLayers[0]; if (!currentSelectedLayer) { return; } // Remove active class from all containers and options content.querySelectorAll('.blend-mode-container').forEach(c => { c.classList.remove('active'); const optionDiv = c.querySelector('.blend-mode-option'); if (optionDiv) { optionDiv.classList.remove('active'); } }); // Add active class to current container and option container.classList.add('active'); option.classList.add('active'); currentSelectedLayer.blendMode = mode.name; this.canvas.render(); }; slider.addEventListener('input', () => { // Re-check selected layer at the time of slider input const currentSelectedLayer = this.canvas.canvasSelection.selectedLayers[0]; if (!currentSelectedLayer) { return; } const newOpacity = parseInt(slider.value, 10) / 100; currentSelectedLayer.opacity = newOpacity; this.canvas.render(); }); slider.addEventListener('change', async () => { if (selectedLayer) { selectedLayer.opacity = parseInt(slider.value, 10) / 100; this.canvas.render(); const saveWithFallback = async (fileName) => { try { const uniqueFileName = generateUniqueFileName(fileName, this.canvas.node.id); return await this.canvas.canvasIO.saveToServer(uniqueFileName); } catch (error) { console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error); return await this.canvas.canvasIO.saveToServer(fileName); } }; if (this.canvas.widget) { await saveWithFallback(this.canvas.widget.value); if (this.canvas.node) { app.graph.runStep(); } } } }); container.appendChild(option); container.appendChild(slider); content.appendChild(container); }); // Add contextmenu event listener to the menu itself to prevent browser context menu menu.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); }); if (!this.canvas.canvasContainer) { log.error("Canvas container not found, cannot append blend mode menu."); return; } this.canvas.canvasContainer.appendChild(menu); this.updateBlendModeMenuPosition(); // Add listener for viewport changes this.canvas.onViewportChange = () => this.updateBlendModeMenuPosition(); const closeMenu = (e) => { if (e.target instanceof Node && !menu.contains(e.target) && !isDragging) { this.closeBlendModeMenu(); if (this.currentCloseMenuListener) { document.removeEventListener('mousedown', this.currentCloseMenuListener); this.currentCloseMenuListener = null; } } }; // Store the listener reference so we can remove it later this.currentCloseMenuListener = closeMenu; setTimeout(() => { document.addEventListener('mousedown', closeMenu); }, 0); } closeBlendModeMenu() { log.info("=== BLEND MODE MENU CLOSING ==="); if (this.blendMenuElement && this.blendMenuElement.parentNode) { log.info("Removing blend mode menu from DOM"); this.blendMenuElement.parentNode.removeChild(this.blendMenuElement); this.blendMenuElement = null; } else { log.info("Blend mode menu not found or already removed"); } // Remove viewport change listener if (this.canvas.onViewportChange) { this.canvas.onViewportChange = null; } } /** * Zunifikowana funkcja do generowania blob z canvas * @param options Opcje renderowania */ async _generateCanvasBlob(options = {}) { const { layers = this.canvas.layers, useOutputBounds = true, applyMask = false, enableLogging = false, customBounds } = options; return new Promise((resolve, reject) => { let bounds; if (customBounds) { bounds = customBounds; } else if (useOutputBounds) { bounds = this.canvas.outputAreaBounds; } else { // Oblicz bounding box dla wybranych warstw let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; layers.forEach((layer) => { const centerX = layer.x + layer.width / 2; const centerY = layer.y + layer.height / 2; const rad = layer.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const halfW = layer.width / 2; const halfH = layer.height / 2; const corners = [ { x: -halfW, y: -halfH }, { x: halfW, y: -halfH }, { x: halfW, y: halfH }, { x: -halfW, y: halfH } ]; corners.forEach(p => { const worldX = centerX + (p.x * cos - p.y * sin); const worldY = centerY + (p.x * sin + p.y * cos); minX = Math.min(minX, worldX); minY = Math.min(minY, worldY); maxX = Math.max(maxX, worldX); maxY = Math.max(maxY, worldY); }); }); const newWidth = Math.ceil(maxX - minX); const newHeight = Math.ceil(maxY - minY); if (newWidth <= 0 || newHeight <= 0) { resolve(null); return; } bounds = { x: minX, y: minY, width: newWidth, height: newHeight }; } const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(bounds.width, bounds.height, '2d', { willReadFrequently: true }); if (!tempCtx) { reject(new Error("Could not create canvas context")); return; } if (enableLogging) { log.info("=== GENERATING OUTPUT CANVAS ==="); log.info(`Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`); log.info(`Canvas Size: ${tempCanvas.width}x${tempCanvas.height}`); log.info(`Context Translation: translate(${-bounds.x}, ${-bounds.y})`); log.info(`Apply Mask: ${applyMask}`); // Log layer positions before rendering layers.forEach((layer, index) => { if (layer.visible) { const relativeToOutput = { x: layer.x - bounds.x, y: layer.y - bounds.y }; log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_bounds(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`); } }); } // Renderuj fragment świata zdefiniowany przez bounds tempCtx.translate(-bounds.x, -bounds.y); this._drawLayers(tempCtx, layers); // Aplikuj maskę jeśli wymagana if (applyMask) { const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const data = imageData.data; // Use optimized getMaskForOutputArea() for better performance // This only processes chunks that overlap with the output area const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea(); if (toolMaskCanvas) { log.debug(`Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height}) for _generateCanvasBlob`); // The optimized mask is already sized and positioned for the output area // So we can apply it directly without complex positioning calculations const maskImageData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height); if (maskImageData) { const maskData = maskImageData.data; for (let i = 0; i < data.length; i += 4) { const originalAlpha = data[i + 3]; const maskAlpha = maskData[i + 3] / 255; const invertedMaskAlpha = 1 - maskAlpha; data[i + 3] = originalAlpha * invertedMaskAlpha; } tempCtx.putImageData(imageData, 0, 0); } } } tempCanvas.toBlob((blob) => { if (blob) { resolve(blob); } else { resolve(null); } }, 'image/png'); }); } // Publiczne metody używające zunifikowanej funkcji async getFlattenedCanvasWithMaskAsBlob() { return this._generateCanvasBlob({ layers: this.canvas.layers, useOutputBounds: true, applyMask: true, enableLogging: true }); } async getFlattenedCanvasAsBlob() { return this._generateCanvasBlob({ layers: this.canvas.layers, useOutputBounds: true, applyMask: false, enableLogging: true }); } async getFlattenedSelectionAsBlob() { if (this.canvas.canvasSelection.selectedLayers.length === 0) { return null; } return this._generateCanvasBlob({ layers: this.canvas.canvasSelection.selectedLayers, useOutputBounds: false, applyMask: false, enableLogging: false }); } async getFlattenedMaskAsBlob() { return new Promise((resolve, reject) => { const bounds = this.canvas.outputAreaBounds; const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(bounds.width, bounds.height, '2d', { willReadFrequently: true }); if (!maskCtx) { reject(new Error("Could not create mask context")); return; } log.info("=== GENERATING MASK BLOB ==="); log.info(`Mask Canvas Size: ${maskCanvas.width}x${maskCanvas.height}`); // Rozpocznij z białą maską (nic nie zamaskowane) maskCtx.fillStyle = '#ffffff'; maskCtx.fillRect(0, 0, bounds.width, bounds.height); // Stwórz canvas do sprawdzenia przezroczystości warstw const { canvas: visibilityCanvas, ctx: visibilityCtx } = createCanvas(bounds.width, bounds.height, '2d', { alpha: true }); if (!visibilityCtx) { reject(new Error("Could not create visibility context")); return; } // Renderuj warstwy z przesunięciem dla output bounds visibilityCtx.translate(-bounds.x, -bounds.y); this._drawLayers(visibilityCtx, this.canvas.layers); // Konwertuj przezroczystość warstw na maskę const visibilityData = visibilityCtx.getImageData(0, 0, bounds.width, bounds.height); const maskData = maskCtx.getImageData(0, 0, bounds.width, bounds.height); for (let i = 0; i < visibilityData.data.length; i += 4) { const alpha = visibilityData.data[i + 3]; const maskValue = 255 - alpha; // Odwróć alpha żeby stworzyć maskę maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue; maskData.data[i + 3] = 255; // Solidna maska } maskCtx.putImageData(maskData, 0, 0); // Aplikuj maskę narzędzia jeśli istnieje - używaj zoptymalizowanej metody const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea(); if (toolMaskCanvas) { log.debug(`[getFlattenedMaskAsBlob] Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height})`); // Zoptymalizowana maska jest już odpowiednio pozycjonowana dla output area // Możemy ją zastosować bezpośrednio const tempMaskData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height); if (tempMaskData) { // Konwertuj dane maski do odpowiedniego formatu for (let i = 0; i < tempMaskData.data.length; i += 4) { const alpha = tempMaskData.data[i + 3]; tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha; tempMaskData.data[i + 3] = 255; // Solidna alpha } // Stwórz tymczasowy canvas dla przetworzonej maski const { canvas: tempMaskCanvas, ctx: tempMaskCtx } = createCanvas(toolMaskCanvas.width, toolMaskCanvas.height, '2d', { willReadFrequently: true }); if (tempMaskCtx) { tempMaskCtx.putImageData(tempMaskData, 0, 0); maskCtx.globalCompositeOperation = 'screen'; maskCtx.drawImage(tempMaskCanvas, 0, 0); } } } log.info("=== MASK BLOB GENERATED ==="); maskCanvas.toBlob((blob) => { if (blob) { resolve(blob); } else { resolve(null); } }, 'image/png'); }); } async fuseLayers() { if (this.canvas.canvasSelection.selectedLayers.length < 2) { showErrorNotification("Please select at least 2 layers to fuse."); return; } log.info(`Fusing ${this.canvas.canvasSelection.selectedLayers.length} selected layers`); try { this.canvas.saveState(); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; this.canvas.canvasSelection.selectedLayers.forEach((layer) => { const centerX = layer.x + layer.width / 2; const centerY = layer.y + layer.height / 2; const rad = layer.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const halfW = layer.width / 2; const halfH = layer.height / 2; const corners = [ { x: -halfW, y: -halfH }, { x: halfW, y: -halfH }, { x: halfW, y: halfH }, { x: -halfW, y: halfH } ]; corners.forEach(p => { const worldX = centerX + (p.x * cos - p.y * sin); const worldY = centerY + (p.x * sin + p.y * cos); minX = Math.min(minX, worldX); minY = Math.min(minY, worldY); maxX = Math.max(maxX, worldX); maxY = Math.max(maxY, worldY); }); }); const fusedWidth = Math.ceil(maxX - minX); const fusedHeight = Math.ceil(maxY - minY); if (fusedWidth <= 0 || fusedHeight <= 0) { log.warn("Calculated fused layer dimensions are invalid"); showErrorNotification("Cannot fuse layers: invalid dimensions calculated."); return; } const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(fusedWidth, fusedHeight, '2d', { willReadFrequently: true }); if (!tempCtx) throw new Error("Could not create canvas context"); tempCtx.translate(-minX, -minY); this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers); const fusedImage = new Image(); fusedImage.src = tempCanvas.toDataURL(); await new Promise((resolve, reject) => { fusedImage.onload = resolve; fusedImage.onerror = reject; }); const minZIndex = Math.min(...this.canvas.canvasSelection.selectedLayers.map((layer) => layer.zIndex)); const imageId = generateUUID(); await saveImage(imageId, fusedImage.src); this.canvas.imageCache.set(imageId, fusedImage.src); const fusedLayer = { id: generateUUID(), image: fusedImage, imageId: imageId, name: 'Fused Layer', x: minX, y: minY, width: fusedWidth, height: fusedHeight, originalWidth: fusedWidth, originalHeight: fusedHeight, rotation: 0, zIndex: minZIndex, blendMode: 'normal', opacity: 1, visible: true }; this.canvas.layers = this.canvas.layers.filter((layer) => !this.canvas.canvasSelection.selectedLayers.includes(layer)); this.canvas.layers.push(fusedLayer); this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex); this.canvas.layers.forEach((layer, index) => { layer.zIndex = index; }); this.canvas.updateSelection([fusedLayer]); this.canvas.render(); this.canvas.saveState(); if (this.canvas.canvasLayersPanel) { this.canvas.canvasLayersPanel.onLayersChanged(); } log.info("Layers fused successfully", { originalLayerCount: this.canvas.canvasSelection.selectedLayers.length, fusedDimensions: { width: fusedWidth, height: fusedHeight }, fusedPosition: { x: minX, y: minY } }); } catch (error) { log.error("Error during layer fusion:", error); showErrorNotification(`Error fusing layers: ${error.message}`); } } }