Files
Comfyui-LayerForge/js/MaskTool.js
Dariusz L 46e92f30e8 Refactor CanvasInteractions for code reuse and clarity
Introduces helper methods to reduce code duplication and improve readability in CanvasInteractions. Mouse coordinate extraction, event prevention, zoom operations, drag-and-drop styling, and layer wheel transformations are now handled by dedicated methods. This refactor centralizes logic, making the codebase easier to maintain and extend.
2025-07-27 00:10:56 +02:00

1515 lines
70 KiB
JavaScript

import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('Mask_tool');
export class MaskTool {
constructor(canvasInstance, callbacks = {}) {
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
this.canvasInstance = canvasInstance;
this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null;
// Initialize chunked mask system
this.maskChunks = new Map();
this.chunkSize = 512;
this.activeChunkBounds = null;
// Create active mask canvas (composite of chunks)
this.activeMaskCanvas = document.createElement('canvas');
const activeMaskCtx = this.activeMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!activeMaskCtx) {
throw new Error("Failed to get 2D context for active mask canvas");
}
this.activeMaskCtx = activeMaskCtx;
this.x = 0;
this.y = 0;
this.isOverlayVisible = true;
this.isActive = false;
this.brushSize = 20;
this.brushStrength = 0.5;
this.brushHardness = 0.5;
this.isDrawing = false;
this.lastPosition = null;
this.previewCanvas = document.createElement('canvas');
const previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
if (!previewCtx) {
throw new Error("Failed to get 2D context for preview canvas");
}
this.previewCtx = previewCtx;
this.previewVisible = false;
this.previewCanvasInitialized = false;
// Initialize shape preview system
this.shapePreviewCanvas = document.createElement('canvas');
const shapePreviewCtx = this.shapePreviewCanvas.getContext('2d', { willReadFrequently: true });
if (!shapePreviewCtx) {
throw new Error("Failed to get 2D context for shape preview canvas");
}
this.shapePreviewCtx = shapePreviewCtx;
this.shapePreviewVisible = false;
this.isPreviewMode = false;
// Initialize performance optimization flags
this.activeMaskNeedsUpdate = false;
this.activeMaskUpdateTimeout = null;
this.initMaskCanvas();
}
// Temporary compatibility getters - will be replaced with chunked system
get maskCanvas() {
return this.activeMaskCanvas;
}
get maskCtx() {
return this.activeMaskCtx;
}
initPreviewCanvas() {
if (this.previewCanvas.parentElement) {
this.previewCanvas.parentElement.removeChild(this.previewCanvas);
}
this.previewCanvas.width = this.canvasInstance.canvas.width;
this.previewCanvas.height = this.canvasInstance.canvas.height;
this.previewCanvas.style.position = 'absolute';
this.previewCanvas.style.left = `${this.canvasInstance.canvas.offsetLeft}px`;
this.previewCanvas.style.top = `${this.canvasInstance.canvas.offsetTop}px`;
this.previewCanvas.style.pointerEvents = 'none';
this.previewCanvas.style.zIndex = '10';
if (this.canvasInstance.canvas.parentElement) {
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
}
}
setBrushHardness(hardness) {
this.brushHardness = Math.max(0, Math.min(1, hardness));
}
initMaskCanvas() {
// Initialize chunked system
this.chunkSize = 512;
this.maskChunks = new Map();
// Create initial active mask canvas
this.updateActiveMaskCanvas();
log.info(`Initialized chunked mask system with chunk size: ${this.chunkSize}x${this.chunkSize}`);
}
/**
* Updates the active mask canvas to show ALL chunks with mask data
* No longer limited to output area - shows all drawn masks everywhere
*/
updateActiveMaskCanvas() {
// Find bounds of all non-empty chunks
const chunkBounds = this.getAllChunkBounds();
if (!chunkBounds) {
// No chunks with data - create minimal canvas
this.activeMaskCanvas.width = 1;
this.activeMaskCanvas.height = 1;
this.x = 0;
this.y = 0;
this.activeChunkBounds = null;
log.info("No mask chunks found - created minimal active canvas");
return;
}
// Calculate canvas size to cover all chunks
const canvasLeft = chunkBounds.minX * this.chunkSize;
const canvasTop = chunkBounds.minY * this.chunkSize;
const canvasWidth = (chunkBounds.maxX - chunkBounds.minX + 1) * this.chunkSize;
const canvasHeight = (chunkBounds.maxY - chunkBounds.minY + 1) * this.chunkSize;
// Update active mask canvas size and position
this.activeMaskCanvas.width = canvasWidth;
this.activeMaskCanvas.height = canvasHeight;
this.x = canvasLeft;
this.y = canvasTop;
// Clear active canvas
this.activeMaskCtx.clearRect(0, 0, canvasWidth, canvasHeight);
this.activeChunkBounds = chunkBounds;
// Composite ALL chunks with data onto active canvas
for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) {
for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) {
const chunkKey = `${chunkX},${chunkY}`;
const chunk = this.maskChunks.get(chunkKey);
if (chunk && !chunk.isEmpty) {
// Calculate position on active canvas
const destX = (chunkX - chunkBounds.minX) * this.chunkSize;
const destY = (chunkY - chunkBounds.minY) * this.chunkSize;
this.activeMaskCtx.drawImage(chunk.canvas, destX, destY);
}
}
}
}
/**
* Finds the bounds of all chunks that contain mask data
* Returns null if no chunks have data
*/
getAllChunkBounds() {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let hasData = false;
for (const [chunkKey, chunk] of this.maskChunks) {
if (!chunk.isEmpty) {
const [chunkXStr, chunkYStr] = chunkKey.split(',');
const chunkX = parseInt(chunkXStr);
const chunkY = parseInt(chunkYStr);
minX = Math.min(minX, chunkX);
minY = Math.min(minY, chunkY);
maxX = Math.max(maxX, chunkX);
maxY = Math.max(maxY, chunkY);
hasData = true;
}
}
return hasData ? { minX, minY, maxX, maxY } : null;
}
/**
* Gets or creates a chunk for the given world coordinates
*/
getChunkForPosition(worldX, worldY) {
const chunkX = Math.floor(worldX / this.chunkSize);
const chunkY = Math.floor(worldY / this.chunkSize);
const chunkKey = `${chunkX},${chunkY}`;
let chunk = this.maskChunks.get(chunkKey);
if (!chunk) {
chunk = this.createChunk(chunkX, chunkY);
this.maskChunks.set(chunkKey, chunk);
}
return chunk;
}
/**
* Creates a new chunk at the given chunk coordinates
*/
createChunk(chunkX, chunkY) {
const canvas = document.createElement('canvas');
canvas.width = this.chunkSize;
canvas.height = this.chunkSize;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
throw new Error("Failed to get 2D context for chunk canvas");
}
const chunk = {
canvas,
ctx,
x: chunkX * this.chunkSize,
y: chunkY * this.chunkSize,
isDirty: false,
isEmpty: true
};
log.debug(`Created chunk at (${chunkX}, ${chunkY}) covering world area (${chunk.x}, ${chunk.y}) to (${chunk.x + this.chunkSize}, ${chunk.y + this.chunkSize})`);
return chunk;
}
activate() {
if (!this.previewCanvasInitialized) {
this.initPreviewCanvas();
this.previewCanvasInitialized = true;
}
this.isActive = true;
this.previewCanvas.style.display = 'block';
this.canvasInstance.interaction.mode = 'drawingMask';
if (this.canvasInstance.canvasState.maskUndoStack.length === 0) {
this.canvasInstance.canvasState.saveMaskState();
}
this.canvasInstance.updateHistoryButtons();
log.info("Mask tool activated");
}
deactivate() {
this.isActive = false;
this.previewCanvas.style.display = 'none';
this.canvasInstance.interaction.mode = 'none';
this.canvasInstance.updateHistoryButtons();
log.info("Mask tool deactivated");
}
setBrushSize(size) {
this.brushSize = Math.max(1, size);
}
setBrushStrength(strength) {
this.brushStrength = Math.max(0, Math.min(1, strength));
}
handleMouseDown(worldCoords, viewCoords) {
if (!this.isActive)
return;
this.isDrawing = true;
this.lastPosition = worldCoords;
this.draw(worldCoords);
this.clearPreview();
}
handleMouseMove(worldCoords, viewCoords) {
if (this.isActive) {
this.drawBrushPreview(viewCoords);
}
if (!this.isActive || !this.isDrawing)
return;
this.draw(worldCoords);
this.lastPosition = worldCoords;
}
handleMouseLeave() {
this.previewVisible = false;
this.clearPreview();
}
handleMouseEnter() {
this.previewVisible = true;
}
handleMouseUp(viewCoords) {
if (!this.isActive)
return;
if (this.isDrawing) {
this.isDrawing = false;
this.lastPosition = null;
this.canvasInstance.canvasState.saveMaskState();
if (this.onStateChange) {
this.onStateChange();
}
this.drawBrushPreview(viewCoords);
}
}
draw(worldCoords) {
if (!this.lastPosition) {
this.lastPosition = worldCoords;
}
// Draw on chunks instead of single canvas
this.drawOnChunks(this.lastPosition, worldCoords);
// Only update active canvas if we drew on chunks that are currently visible
// This prevents unnecessary recomposition during drawing
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
}
/**
* Draws a line between two world coordinates on the appropriate chunks
*/
drawOnChunks(startWorld, endWorld) {
// Calculate all chunks that this line might touch
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
const chunkMinX = Math.floor(minX / this.chunkSize);
const chunkMinY = Math.floor(minY / this.chunkSize);
const chunkMaxX = Math.floor(maxX / this.chunkSize);
const chunkMaxY = Math.floor(maxY / this.chunkSize);
// Draw on all affected chunks
for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
this.drawLineOnChunk(chunk, startWorld, endWorld);
}
}
}
/**
* Draws a line on a specific chunk
*/
drawLineOnChunk(chunk, startWorld, endWorld) {
// Convert world coordinates to chunk-local coordinates
const startLocal = {
x: startWorld.x - chunk.x,
y: startWorld.y - chunk.y
};
const endLocal = {
x: endWorld.x - chunk.x,
y: endWorld.y - chunk.y
};
// Check if the line intersects this chunk
if (!this.lineIntersectsChunk(startLocal, endLocal, this.chunkSize)) {
return;
}
// Draw the line on this chunk
chunk.ctx.beginPath();
chunk.ctx.moveTo(startLocal.x, startLocal.y);
chunk.ctx.lineTo(endLocal.x, endLocal.y);
const gradientRadius = this.brushSize / 2;
if (this.brushHardness === 1) {
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
}
else {
const innerRadius = gradientRadius * this.brushHardness;
const gradient = chunk.ctx.createRadialGradient(endLocal.x, endLocal.y, innerRadius, endLocal.x, endLocal.y, gradientRadius);
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
chunk.ctx.strokeStyle = gradient;
}
chunk.ctx.lineWidth = this.brushSize;
chunk.ctx.lineCap = 'round';
chunk.ctx.lineJoin = 'round';
chunk.ctx.globalCompositeOperation = 'source-over';
chunk.ctx.stroke();
// Mark chunk as dirty and not empty
chunk.isDirty = true;
chunk.isEmpty = false;
log.debug(`Drew on chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)})`);
}
/**
* Checks if a line intersects with a chunk bounds
*/
lineIntersectsChunk(startLocal, endLocal, chunkSize) {
// Expand bounds by brush size to catch partial intersections
const margin = this.brushSize / 2;
const left = -margin;
const top = -margin;
const right = chunkSize + margin;
const bottom = chunkSize + margin;
// Check if either point is inside the expanded bounds
if ((startLocal.x >= left && startLocal.x <= right && startLocal.y >= top && startLocal.y <= bottom) ||
(endLocal.x >= left && endLocal.x <= right && endLocal.y >= top && endLocal.y <= bottom)) {
return true;
}
// Check if line crosses chunk bounds (simplified check)
return true; // For now, always draw - more precise intersection can be added later
}
/**
* Updates active canvas when drawing affects chunks with throttling to prevent lag
* Uses throttling to limit updates to ~60fps during drawing operations
*/
updateActiveCanvasIfNeeded(startWorld, endWorld) {
// Calculate which chunks were affected by this drawing operation
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
const affectedChunkMinX = Math.floor(minX / this.chunkSize);
const affectedChunkMinY = Math.floor(minY / this.chunkSize);
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
// Check if we drew on any new chunks (outside current active bounds)
let drewOnNewChunks = false;
if (!this.activeChunkBounds) {
drewOnNewChunks = true;
}
else {
drewOnNewChunks =
affectedChunkMinX < this.activeChunkBounds.minX ||
affectedChunkMaxX > this.activeChunkBounds.maxX ||
affectedChunkMinY < this.activeChunkBounds.minY ||
affectedChunkMaxY > this.activeChunkBounds.maxY;
}
if (drewOnNewChunks) {
// Drawing extended beyond current active bounds - immediate update required
this.updateActiveMaskCanvas();
log.debug("Drew on new chunks - performed immediate full active canvas update");
}
else {
// Drawing within existing bounds - use throttled update for performance
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY);
}
}
/**
* Schedules a throttled update of the active mask canvas to prevent excessive redraws
* Only updates at most once per ACTIVE_MASK_UPDATE_DELAY milliseconds
*/
scheduleThrottledActiveMaskUpdate(chunkMinX, chunkMinY, chunkMaxX, chunkMaxY) {
// Mark that an update is needed
this.activeMaskNeedsUpdate = true;
// If there's already a pending update, don't schedule another one
if (this.activeMaskUpdateTimeout !== null) {
return;
}
// Schedule the update with throttling
this.activeMaskUpdateTimeout = window.setTimeout(() => {
if (this.activeMaskNeedsUpdate) {
// Perform partial update for the affected chunks
this.updateActiveCanvasPartial(chunkMinX, chunkMinY, chunkMaxX, chunkMaxY);
this.activeMaskNeedsUpdate = false;
log.debug("Performed throttled partial active canvas update");
}
this.activeMaskUpdateTimeout = null;
}, this.ACTIVE_MASK_UPDATE_DELAY);
}
/**
* Partially updates the active canvas by redrawing only specific chunks
* Much faster than full recomposition during drawing
* Now works with the new system that shows ALL chunks
*/
updateActiveCanvasPartial(chunkMinX, chunkMinY, chunkMaxX, chunkMaxY) {
if (!this.activeChunkBounds) {
// No active bounds - do full update
this.updateActiveMaskCanvas();
return;
}
// Only redraw the affected chunks that are within the current active canvas bounds
for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
// Check if this chunk is within active bounds (all chunks with data)
if (chunkX >= this.activeChunkBounds.minX && chunkX <= this.activeChunkBounds.maxX &&
chunkY >= this.activeChunkBounds.minY && chunkY <= this.activeChunkBounds.maxY) {
const chunkKey = `${chunkX},${chunkY}`;
const chunk = this.maskChunks.get(chunkKey);
if (chunk && !chunk.isEmpty) {
// Calculate position on active canvas (relative to all chunks bounds)
const destX = (chunkX - this.activeChunkBounds.minX) * this.chunkSize;
const destY = (chunkY - this.activeChunkBounds.minY) * this.chunkSize;
// Clear the area first, then redraw
this.activeMaskCtx.clearRect(destX, destY, this.chunkSize, this.chunkSize);
this.activeMaskCtx.drawImage(chunk.canvas, destX, destY);
}
}
}
}
}
drawBrushPreview(viewCoords) {
if (!this.previewVisible || this.isDrawing) {
this.clearPreview();
return;
}
this.clearPreview();
const zoom = this.canvasInstance.viewport.zoom;
const radius = (this.brushSize / 2) * zoom;
this.previewCtx.beginPath();
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
this.previewCtx.lineWidth = 1;
this.previewCtx.setLineDash([2, 4]);
this.previewCtx.stroke();
}
clearPreview() {
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
this.clearShapePreview();
}
/**
* Initialize shape preview canvas for showing blue outline during slider adjustments
* Canvas is pinned to viewport and covers the entire visible area
*/
initShapePreviewCanvas() {
if (this.shapePreviewCanvas.parentElement) {
this.shapePreviewCanvas.parentElement.removeChild(this.shapePreviewCanvas);
}
// Canvas covers entire viewport - pinned to screen, not world
this.shapePreviewCanvas.width = this.canvasInstance.canvas.width;
this.shapePreviewCanvas.height = this.canvasInstance.canvas.height;
// Pin canvas to viewport - no world coordinate positioning
this.shapePreviewCanvas.style.position = 'absolute';
this.shapePreviewCanvas.style.left = '0px';
this.shapePreviewCanvas.style.top = '0px';
this.shapePreviewCanvas.style.width = '100%';
this.shapePreviewCanvas.style.height = '100%';
this.shapePreviewCanvas.style.pointerEvents = 'none';
this.shapePreviewCanvas.style.zIndex = '15'; // Above regular preview
this.shapePreviewCanvas.style.imageRendering = 'pixelated'; // Sharp rendering
if (this.canvasInstance.canvas.parentElement) {
this.canvasInstance.canvas.parentElement.appendChild(this.shapePreviewCanvas);
}
}
/**
* Show blue outline preview of expansion/contraction during slider adjustment
*/
showShapePreview(expansionValue, featherValue = 0) {
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
return;
}
if (!this.shapePreviewCanvas.parentElement)
this.initShapePreviewCanvas();
this.isPreviewMode = true;
this.shapePreviewVisible = true;
this.shapePreviewCanvas.style.display = 'block';
this.clearShapePreview();
const shape = this.canvasInstance.outputAreaShape;
const viewport = this.canvasInstance.viewport;
const bounds = this.canvasInstance.outputAreaBounds;
// Convert shape points to world coordinates first (relative to output area bounds)
const worldShapePoints = shape.points.map(p => ({
x: bounds.x + p.x,
y: bounds.y + p.y
}));
// Then convert world coordinates to screen coordinates
const screenPoints = worldShapePoints.map(p => ({
x: (p.x - viewport.x) * viewport.zoom,
y: (p.y - viewport.y) * viewport.zoom
}));
// This function now returns Point[][] to handle islands.
const allContours = this._calculatePreviewPointsScreen([screenPoints], expansionValue, viewport.zoom);
// Draw main expansion/contraction preview
this.shapePreviewCtx.strokeStyle = '#4A9EFF';
this.shapePreviewCtx.lineWidth = 2;
this.shapePreviewCtx.setLineDash([4, 4]);
this.shapePreviewCtx.globalAlpha = 0.8;
for (const contour of allContours) {
if (contour.length < 2)
continue;
this.shapePreviewCtx.beginPath();
this.shapePreviewCtx.moveTo(contour[0].x, contour[0].y);
for (let i = 1; i < contour.length; i++) {
this.shapePreviewCtx.lineTo(contour[i].x, contour[i].y);
}
this.shapePreviewCtx.closePath();
this.shapePreviewCtx.stroke();
}
// Draw feather preview
if (featherValue > 0) {
const allFeatherContours = this._calculatePreviewPointsScreen(allContours, -featherValue, viewport.zoom);
this.shapePreviewCtx.strokeStyle = '#4A9EFF';
this.shapePreviewCtx.lineWidth = 1;
this.shapePreviewCtx.setLineDash([3, 5]);
this.shapePreviewCtx.globalAlpha = 0.6;
for (const contour of allFeatherContours) {
if (contour.length < 2)
continue;
this.shapePreviewCtx.beginPath();
this.shapePreviewCtx.moveTo(contour[0].x, contour[0].y);
for (let i = 1; i < contour.length; i++) {
this.shapePreviewCtx.lineTo(contour[i].x, contour[i].y);
}
this.shapePreviewCtx.closePath();
this.shapePreviewCtx.stroke();
}
}
log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px at bounds (${bounds.x}, ${bounds.y})`);
}
/**
* Hide shape preview and switch back to normal mode
*/
hideShapePreview() {
this.isPreviewMode = false;
this.shapePreviewVisible = false;
this.clearShapePreview();
this.shapePreviewCanvas.style.display = 'none';
log.debug("Shape preview hidden");
}
/**
* Clear shape preview canvas
*/
clearShapePreview() {
if (this.shapePreviewCtx) {
this.shapePreviewCtx.clearRect(0, 0, this.shapePreviewCanvas.width, this.shapePreviewCanvas.height);
}
}
/**
* Update shape preview canvas position and scale when viewport changes
* This ensures the preview stays synchronized with the world coordinates
*/
updateShapePreviewPosition() {
if (!this.shapePreviewCanvas.parentElement || !this.shapePreviewVisible) {
return;
}
const viewport = this.canvasInstance.viewport;
const bufferSize = 300;
// Calculate world position (output area + buffer)
const previewX = -bufferSize; // World coordinates
const previewY = -bufferSize;
// Convert to screen coordinates
const screenX = (previewX - viewport.x) * viewport.zoom;
const screenY = (previewY - viewport.y) * viewport.zoom;
// Update position and scale
this.shapePreviewCanvas.style.left = `${screenX}px`;
this.shapePreviewCanvas.style.top = `${screenY}px`;
const previewWidth = this.canvasInstance.width + (bufferSize * 2);
const previewHeight = this.canvasInstance.height + (bufferSize * 2);
this.shapePreviewCanvas.style.width = `${previewWidth * viewport.zoom}px`;
this.shapePreviewCanvas.style.height = `${previewHeight * viewport.zoom}px`;
}
/**
* Ultra-fast dilation using Distance Transform + thresholding (Manhattan distance for speed)
*/
_fastDilateDT(mask, width, height, radius) {
const INF = 1e9;
const dist = new Float32Array(width * height);
// 1. Initialize: 0 for foreground, INF for background
for (let i = 0; i < width * height; ++i) {
dist[i] = mask[i] ? 0 : INF;
}
// 2. Forward pass: top-left -> bottom-right
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
const i = y * width + x;
if (mask[i])
continue;
if (x > 0)
dist[i] = Math.min(dist[i], dist[y * width + (x - 1)] + 1);
if (y > 0)
dist[i] = Math.min(dist[i], dist[(y - 1) * width + x] + 1);
}
}
// 3. Backward pass: bottom-right -> top-left
for (let y = height - 1; y >= 0; --y) {
for (let x = width - 1; x >= 0; --x) {
const i = y * width + x;
if (mask[i])
continue;
if (x < width - 1)
dist[i] = Math.min(dist[i], dist[y * width + (x + 1)] + 1);
if (y < height - 1)
dist[i] = Math.min(dist[i], dist[(y + 1) * width + x] + 1);
}
}
// 4. Thresholding: if distance <= radius, it's part of the expanded mask
const expanded = new Uint8Array(width * height);
for (let i = 0; i < width * height; ++i) {
expanded[i] = dist[i] <= radius ? 1 : 0;
}
return expanded;
}
/**
* Ultra-fast erosion using Distance Transform + thresholding
*/
_fastErodeDT(mask, width, height, radius) {
const INF = 1e9;
const dist = new Float32Array(width * height);
// 1. Initialize: 0 for background, INF for foreground (inverse of dilation)
for (let i = 0; i < width * height; ++i) {
dist[i] = mask[i] ? INF : 0;
}
// 2. Forward pass: top-left -> bottom-right
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
const i = y * width + x;
if (!mask[i])
continue;
if (x > 0)
dist[i] = Math.min(dist[i], dist[y * width + (x - 1)] + 1);
if (y > 0)
dist[i] = Math.min(dist[i], dist[(y - 1) * width + x] + 1);
}
}
// 3. Backward pass: bottom-right -> top-left
for (let y = height - 1; y >= 0; --y) {
for (let x = width - 1; x >= 0; --x) {
const i = y * width + x;
if (!mask[i])
continue;
if (x < width - 1)
dist[i] = Math.min(dist[i], dist[y * width + (x + 1)] + 1);
if (y < height - 1)
dist[i] = Math.min(dist[i], dist[(y + 1) * width + x] + 1);
}
}
// 4. Thresholding: if distance > radius, it's part of the eroded mask
const eroded = new Uint8Array(width * height);
for (let i = 0; i < width * height; ++i) {
eroded[i] = dist[i] > radius ? 1 : 0;
}
return eroded;
}
/**
* Calculate preview points using screen coordinates for pinned canvas.
* This version now accepts multiple contours and returns multiple contours.
*/
_calculatePreviewPointsScreen(contours, expansionValue, zoom) {
if (contours.length === 0 || expansionValue === 0)
return contours;
const width = this.canvasInstance.canvas.width;
const height = this.canvasInstance.canvas.height;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = width;
tempCanvas.height = height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
// Draw all contours to create the initial mask
tempCtx.fillStyle = 'white';
for (const points of contours) {
if (points.length < 3)
continue;
tempCtx.beginPath();
tempCtx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
tempCtx.lineTo(points[i].x, points[i].y);
}
tempCtx.closePath();
tempCtx.fill('evenodd'); // Use evenodd to handle holes correctly
}
const maskImage = tempCtx.getImageData(0, 0, width, height);
const binaryData = new Uint8Array(width * height);
for (let i = 0; i < binaryData.length; i++) {
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0;
}
let resultMask;
const scaledExpansionValue = Math.round(Math.abs(expansionValue * zoom));
if (expansionValue >= 0) {
resultMask = this._fastDilateDT(binaryData, width, height, scaledExpansionValue);
}
else {
resultMask = this._fastErodeDT(binaryData, width, height, scaledExpansionValue);
}
// Extract all contours (outer and inner) from the resulting mask
const allResultContours = this._traceAllContours(resultMask, width, height);
return allResultContours.length > 0 ? allResultContours : contours;
}
/**
* Calculate preview points in world coordinates using morphological operations
* This version works directly with mask canvas coordinates
*/
/**
* Traces all contours (outer and inner islands) from a binary mask.
* @returns An array of contours, where each contour is an array of points.
*/
_traceAllContours(mask, width, height) {
const contours = [];
const visited = new Uint8Array(mask.length); // Keep track of visited pixels
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
// Check for a potential starting point: a foreground pixel that hasn't been visited
// and is on a boundary (next to a background pixel).
if (mask[idx] === 1 && visited[idx] === 0) {
// Check if it's a boundary pixel
const isBoundary = mask[idx - 1] === 0 ||
mask[idx + 1] === 0 ||
mask[idx - width] === 0 ||
mask[idx + width] === 0;
if (isBoundary) {
// Found a new contour, let's trace it.
const contour = this._traceSingleContour({ x, y }, mask, width, height, visited);
if (contour.length > 2) {
// --- Path Simplification ---
const simplifiedContour = [];
const simplificationFactor = Math.max(1, Math.floor(contour.length / 200));
for (let i = 0; i < contour.length; i += simplificationFactor) {
simplifiedContour.push(contour[i]);
}
contours.push(simplifiedContour);
}
}
}
}
}
return contours;
}
/**
* Traces a single contour from a starting point using Moore-Neighbor algorithm.
*/
_traceSingleContour(startPoint, mask, width, height, visited) {
const contour = [];
let { x, y } = startPoint;
// Neighbor checking order (clockwise)
const neighbors = [
{ dx: 0, dy: -1 }, // N
{ dx: 1, dy: -1 }, // NE
{ dx: 1, dy: 0 }, // E
{ dx: 1, dy: 1 }, // SE
{ dx: 0, dy: 1 }, // S
{ dx: -1, dy: 1 }, // SW
{ dx: -1, dy: 0 }, // W
{ dx: -1, dy: -1 } // NW
];
let initialNeighborIndex = 0;
do {
let foundNext = false;
for (let i = 0; i < 8; i++) {
const neighborIndex = (initialNeighborIndex + i) % 8;
const nx = x + neighbors[neighborIndex].dx;
const ny = y + neighbors[neighborIndex].dy;
const nIdx = ny * width + nx;
if (nx >= 0 && nx < width && ny >= 0 && ny < height && mask[nIdx] === 1) {
contour.push({ x, y });
visited[y * width + x] = 1; // Mark current point as visited
x = nx;
y = ny;
initialNeighborIndex = (neighborIndex + 5) % 8;
foundNext = true;
break;
}
}
if (!foundNext)
break; // End if no next point found
} while (x !== startPoint.x || y !== startPoint.y);
return contour;
}
clear() {
// Clear all mask chunks instead of just the active canvas
this.clearAllMaskChunks();
// Update active mask canvas to reflect the cleared state
this.updateActiveMaskCanvas();
if (this.isActive) {
this.canvasInstance.canvasState.saveMaskState();
}
// Trigger render to show the cleared mask
this.canvasInstance.render();
log.info("Cleared all mask data from all chunks");
}
getMask() {
// Return the current active mask canvas which shows all chunks
// Only update if there are pending changes to avoid unnecessary redraws
if (this.activeMaskNeedsUpdate) {
this.updateActiveMaskCanvas();
this.activeMaskNeedsUpdate = false;
}
return this.activeMaskCanvas;
}
resize(width, height) {
this.initPreviewCanvas();
const oldMask = this.maskCanvas;
const oldX = this.x;
const oldY = this.y;
const oldWidth = oldMask.width;
const oldHeight = oldMask.height;
const isIncreasingWidth = width > this.canvasInstance.width;
const isIncreasingHeight = height > this.canvasInstance.height;
this.activeMaskCanvas = document.createElement('canvas');
const extraSpace = 2000;
const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace);
const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace);
this.activeMaskCanvas.width = newWidth;
this.activeMaskCanvas.height = newHeight;
const newMaskCtx = this.activeMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!newMaskCtx) {
throw new Error("Failed to get 2D context for new mask canvas");
}
this.activeMaskCtx = newMaskCtx;
if (oldMask.width > 0 && oldMask.height > 0) {
const offsetX = this.x - oldX;
const offsetY = this.y - oldY;
this.activeMaskCtx.drawImage(oldMask, offsetX, offsetY);
log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`);
}
log.info(`Mask canvas resized to ${this.activeMaskCanvas.width}x${this.activeMaskCanvas.height}, position (${this.x}, ${this.y})`);
log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`);
}
/**
* Updates mask canvas to ensure it covers the current output area
* This should be called when output area position or size changes
* Now uses chunked system - just updates the active mask canvas
*/
updateMaskCanvasForOutputArea() {
log.info(`Updating chunked mask system for output area at (${this.canvasInstance.outputAreaBounds.x}, ${this.canvasInstance.outputAreaBounds.y})`);
// Simply update the active mask canvas to cover the new output area
// All existing chunks are preserved in the maskChunks Map
this.updateActiveMaskCanvas();
log.info(`Chunked mask system updated - ${this.maskChunks.size} chunks preserved`);
}
toggleOverlayVisibility() {
this.isOverlayVisible = !this.isOverlayVisible;
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
}
setMask(image) {
// Clear existing mask chunks in the output area first
const bounds = this.canvasInstance.outputAreaBounds;
this.clearMaskInArea(bounds.x, bounds.y, image.width, image.height);
// Add the new mask using the chunk system
this.addMask(image);
log.info(`MaskTool set new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
}
/**
* Clears mask data in a specific area by clearing affected chunks
*/
clearMaskInArea(x, y, width, height) {
const chunkMinX = Math.floor(x / this.chunkSize);
const chunkMinY = Math.floor(y / this.chunkSize);
const chunkMaxX = Math.floor((x + width) / this.chunkSize);
const chunkMaxY = Math.floor((y + height) / this.chunkSize);
// Clear all affected chunks
for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
const chunkKey = `${chunkX},${chunkY}`;
const chunk = this.maskChunks.get(chunkKey);
if (chunk && !chunk.isEmpty) {
this.clearMaskFromChunk(chunk, x, y, width, height);
}
}
}
}
/**
* Clears mask data from a specific chunk in a given area
*/
clearMaskFromChunk(chunk, clearX, clearY, clearWidth, clearHeight) {
// Calculate the intersection of the clear area with this chunk
const chunkLeft = chunk.x;
const chunkTop = chunk.y;
const chunkRight = chunk.x + this.chunkSize;
const chunkBottom = chunk.y + this.chunkSize;
const clearLeft = clearX;
const clearTop = clearY;
const clearRight = clearX + clearWidth;
const clearBottom = clearY + clearHeight;
// Find intersection
const intersectLeft = Math.max(chunkLeft, clearLeft);
const intersectTop = Math.max(chunkTop, clearTop);
const intersectRight = Math.min(chunkRight, clearRight);
const intersectBottom = Math.min(chunkBottom, clearBottom);
// Check if there's actually an intersection
if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) {
return; // No intersection
}
// Calculate destination coordinates on the chunk
const destX = intersectLeft - chunkLeft;
const destY = intersectTop - chunkTop;
const destWidth = intersectRight - intersectLeft;
const destHeight = intersectBottom - intersectTop;
// Clear the area on this chunk
chunk.ctx.clearRect(destX, destY, destWidth, destHeight);
// Check if the entire chunk is now empty
const imageData = chunk.ctx.getImageData(0, 0, this.chunkSize, this.chunkSize);
const data = imageData.data;
let hasData = false;
for (let i = 3; i < data.length; i += 4) { // Check alpha channel
if (data[i] > 0) {
hasData = true;
break;
}
}
chunk.isEmpty = !hasData;
chunk.isDirty = true;
log.debug(`Cleared area from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
}
/**
* Clears all mask chunks - used by the clear() function
*/
clearAllMaskChunks() {
// Clear all existing chunks
for (const [chunkKey, chunk] of this.maskChunks) {
chunk.ctx.clearRect(0, 0, this.chunkSize, this.chunkSize);
chunk.isEmpty = true;
chunk.isDirty = true;
}
// Optionally remove all chunks from memory to free up resources
this.maskChunks.clear();
this.activeChunkBounds = null;
log.info(`Cleared all ${this.maskChunks.size} mask chunks`);
}
addMask(image) {
// Add mask to chunks system instead of directly to active canvas
const bounds = this.canvasInstance.outputAreaBounds;
// Calculate which chunks this mask will affect
const maskLeft = bounds.x;
const maskTop = bounds.y;
const maskRight = bounds.x + image.width;
const maskBottom = bounds.y + image.height;
const chunkMinX = Math.floor(maskLeft / this.chunkSize);
const chunkMinY = Math.floor(maskTop / this.chunkSize);
const chunkMaxX = Math.floor(maskRight / this.chunkSize);
const chunkMaxY = Math.floor(maskBottom / this.chunkSize);
// Add mask to all affected chunks
for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
this.addMaskToChunk(chunk, image, bounds);
}
}
// Update active canvas to show the new mask
this.updateActiveMaskCanvas();
if (this.onStateChange) {
this.onStateChange();
}
this.canvasInstance.render();
log.info(`MaskTool added SAM mask to chunks covering bounds (${bounds.x}, ${bounds.y}) to (${maskRight}, ${maskBottom})`);
}
/**
* Adds a mask image to a specific chunk
*/
addMaskToChunk(chunk, maskImage, bounds) {
// Calculate the intersection of the mask with this chunk
const chunkLeft = chunk.x;
const chunkTop = chunk.y;
const chunkRight = chunk.x + this.chunkSize;
const chunkBottom = chunk.y + this.chunkSize;
const maskLeft = bounds.x;
const maskTop = bounds.y;
const maskRight = bounds.x + maskImage.width;
const maskBottom = bounds.y + maskImage.height;
// Find intersection
const intersectLeft = Math.max(chunkLeft, maskLeft);
const intersectTop = Math.max(chunkTop, maskTop);
const intersectRight = Math.min(chunkRight, maskRight);
const intersectBottom = Math.min(chunkBottom, maskBottom);
// Check if there's actually an intersection
if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) {
return; // No intersection
}
// Calculate source coordinates on the mask image
const srcX = intersectLeft - maskLeft;
const srcY = intersectTop - maskTop;
const srcWidth = intersectRight - intersectLeft;
const srcHeight = intersectBottom - intersectTop;
// Calculate destination coordinates on the chunk
const destX = intersectLeft - chunkLeft;
const destY = intersectTop - chunkTop;
// Draw the mask portion onto this chunk
chunk.ctx.globalCompositeOperation = 'source-over';
chunk.ctx.drawImage(maskImage, srcX, srcY, srcWidth, srcHeight, // Source rectangle
destX, destY, srcWidth, srcHeight // Destination rectangle
);
// Mark chunk as dirty and not empty
chunk.isDirty = true;
chunk.isEmpty = false;
log.debug(`Added mask to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
}
/**
* Applies a mask canvas to the chunked system at a specific world position
*/
applyMaskCanvasToChunks(maskCanvas, worldX, worldY) {
// Calculate which chunks this mask will affect
const maskLeft = worldX;
const maskTop = worldY;
const maskRight = worldX + maskCanvas.width;
const maskBottom = worldY + maskCanvas.height;
const chunkMinX = Math.floor(maskLeft / this.chunkSize);
const chunkMinY = Math.floor(maskTop / this.chunkSize);
const chunkMaxX = Math.floor(maskRight / this.chunkSize);
const chunkMaxY = Math.floor(maskBottom / this.chunkSize);
// First, clear the area where the mask will be applied
this.clearMaskInArea(maskLeft, maskTop, maskCanvas.width, maskCanvas.height);
// Apply mask to all affected chunks
for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
this.applyMaskCanvasToChunk(chunk, maskCanvas, worldX, worldY);
}
}
log.info(`Applied mask canvas to chunks covering area (${maskLeft}, ${maskTop}) to (${maskRight}, ${maskBottom})`);
}
/**
* Removes a mask canvas from the chunked system at a specific world position
*/
removeMaskCanvasFromChunks(maskCanvas, worldX, worldY) {
// Calculate which chunks this mask will affect
const maskLeft = worldX;
const maskTop = worldY;
const maskRight = worldX + maskCanvas.width;
const maskBottom = worldY + maskCanvas.height;
const chunkMinX = Math.floor(maskLeft / this.chunkSize);
const chunkMinY = Math.floor(maskTop / this.chunkSize);
const chunkMaxX = Math.floor(maskRight / this.chunkSize);
const chunkMaxY = Math.floor(maskBottom / this.chunkSize);
// Remove mask from all affected chunks
for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
this.removeMaskCanvasFromChunk(chunk, maskCanvas, worldX, worldY);
}
}
log.info(`Removed mask canvas from chunks covering area (${maskLeft}, ${maskTop}) to (${maskRight}, ${maskBottom})`);
}
/**
* Removes a mask canvas from a specific chunk using destination-out composition
*/
removeMaskCanvasFromChunk(chunk, maskCanvas, maskWorldX, maskWorldY) {
// Calculate the intersection of the mask with this chunk
const chunkLeft = chunk.x;
const chunkTop = chunk.y;
const chunkRight = chunk.x + this.chunkSize;
const chunkBottom = chunk.y + this.chunkSize;
const maskLeft = maskWorldX;
const maskTop = maskWorldY;
const maskRight = maskWorldX + maskCanvas.width;
const maskBottom = maskWorldY + maskCanvas.height;
// Find intersection
const intersectLeft = Math.max(chunkLeft, maskLeft);
const intersectTop = Math.max(chunkTop, maskTop);
const intersectRight = Math.min(chunkRight, maskRight);
const intersectBottom = Math.min(chunkBottom, maskBottom);
// Check if there's actually an intersection
if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) {
return; // No intersection
}
// Calculate source coordinates on the mask canvas
const srcX = intersectLeft - maskLeft;
const srcY = intersectTop - maskTop;
const srcWidth = intersectRight - intersectLeft;
const srcHeight = intersectBottom - intersectTop;
// Calculate destination coordinates on the chunk
const destX = intersectLeft - chunkLeft;
const destY = intersectTop - chunkTop;
// Use destination-out to remove the mask portion from this chunk
chunk.ctx.globalCompositeOperation = 'destination-out';
chunk.ctx.drawImage(maskCanvas, srcX, srcY, srcWidth, srcHeight, // Source rectangle
destX, destY, srcWidth, srcHeight // Destination rectangle
);
// Restore normal composition mode
chunk.ctx.globalCompositeOperation = 'source-over';
// Check if the chunk is now empty
const imageData = chunk.ctx.getImageData(0, 0, this.chunkSize, this.chunkSize);
const data = imageData.data;
let hasData = false;
for (let i = 3; i < data.length; i += 4) { // Check alpha channel
if (data[i] > 0) {
hasData = true;
break;
}
}
chunk.isEmpty = !hasData;
chunk.isDirty = true;
log.debug(`Removed mask canvas from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
}
/**
* Applies a mask canvas to a specific chunk
*/
applyMaskCanvasToChunk(chunk, maskCanvas, maskWorldX, maskWorldY) {
// Calculate the intersection of the mask with this chunk
const chunkLeft = chunk.x;
const chunkTop = chunk.y;
const chunkRight = chunk.x + this.chunkSize;
const chunkBottom = chunk.y + this.chunkSize;
const maskLeft = maskWorldX;
const maskTop = maskWorldY;
const maskRight = maskWorldX + maskCanvas.width;
const maskBottom = maskWorldY + maskCanvas.height;
// Find intersection
const intersectLeft = Math.max(chunkLeft, maskLeft);
const intersectTop = Math.max(chunkTop, maskTop);
const intersectRight = Math.min(chunkRight, maskRight);
const intersectBottom = Math.min(chunkBottom, maskBottom);
// Check if there's actually an intersection
if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) {
return; // No intersection
}
// Calculate source coordinates on the mask canvas
const srcX = intersectLeft - maskLeft;
const srcY = intersectTop - maskTop;
const srcWidth = intersectRight - intersectLeft;
const srcHeight = intersectBottom - intersectTop;
// Calculate destination coordinates on the chunk
const destX = intersectLeft - chunkLeft;
const destY = intersectTop - chunkTop;
// Draw the mask portion onto this chunk
chunk.ctx.globalCompositeOperation = 'source-over';
chunk.ctx.drawImage(maskCanvas, srcX, srcY, srcWidth, srcHeight, // Source rectangle
destX, destY, srcWidth, srcHeight // Destination rectangle
);
// Mark chunk as dirty and not empty
chunk.isDirty = true;
chunk.isEmpty = false;
log.debug(`Applied mask canvas to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
}
applyShapeMask(saveState = true) {
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
log.warn("Cannot apply shape mask: shape is not defined or has too few points.");
return;
}
if (saveState) {
this.canvasInstance.canvasState.saveMaskState();
}
const shape = this.canvasInstance.outputAreaShape;
const bounds = this.canvasInstance.outputAreaBounds;
// Calculate shape points in world coordinates
// Shape points are relative to the output area bounds
const worldShapePoints = shape.points.map(p => ({
x: bounds.x + p.x,
y: bounds.y + p.y
}));
// Create the shape mask canvas
let shapeMaskCanvas;
// Check if we need expansion or feathering
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
const needsFeather = this.canvasInstance.shapeMaskFeather && this.canvasInstance.shapeMaskFeatherValue > 0;
// Create a temporary canvas large enough to contain the shape and any expansion
const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0));
const tempCanvasWidth = bounds.width + (maxExpansion * 2);
const tempCanvasHeight = bounds.height + (maxExpansion * 2);
const tempOffsetX = maxExpansion;
const tempOffsetY = maxExpansion;
// Adjust shape points for the temporary canvas
const tempShapePoints = worldShapePoints.map(p => ({
x: p.x - bounds.x + tempOffsetX,
y: p.y - bounds.y + tempOffsetY
}));
if (!needsExpansion && !needsFeather) {
// Simple case: just draw the original shape
shapeMaskCanvas = document.createElement('canvas');
shapeMaskCanvas.width = tempCanvasWidth;
shapeMaskCanvas.height = tempCanvasHeight;
const ctx = shapeMaskCanvas.getContext('2d', { willReadFrequently: true });
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.moveTo(tempShapePoints[0].x, tempShapePoints[0].y);
for (let i = 1; i < tempShapePoints.length; i++) {
ctx.lineTo(tempShapePoints[i].x, tempShapePoints[i].y);
}
ctx.closePath();
ctx.fill('evenodd');
}
else if (needsExpansion && !needsFeather) {
// Expansion only
shapeMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight);
}
else if (!needsExpansion && needsFeather) {
// Feather only
shapeMaskCanvas = this._createFeatheredMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskFeatherValue, tempCanvasWidth, tempCanvasHeight);
}
else {
// Both expansion and feather
const expandedMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight);
const tempCtx = expandedMaskCanvas.getContext('2d', { willReadFrequently: true });
const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height);
shapeMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, tempCanvasWidth, tempCanvasHeight);
}
// Now apply the shape mask to the chunked system
this.applyMaskCanvasToChunks(shapeMaskCanvas, bounds.x - tempOffsetX, bounds.y - tempOffsetY);
// Update the active mask canvas to show the changes
this.updateActiveMaskCanvas();
if (this.onStateChange) {
this.onStateChange();
}
this.canvasInstance.render();
log.info(`Applied shape mask to chunks with expansion: ${needsExpansion}, feather: ${needsFeather}.`);
}
/**
* Removes mask in the area of the custom output area shape. This must use a hard-edged
* shape to correctly erase any feathered "glow" that might have been applied.
* Now works with the chunked mask system.
*/
removeShapeMask() {
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
log.warn("Shape has insufficient points for mask removal");
return;
}
this.canvasInstance.canvasState.saveMaskState();
const shape = this.canvasInstance.outputAreaShape;
const bounds = this.canvasInstance.outputAreaBounds;
// Calculate shape points in world coordinates (same as applyShapeMask)
const worldShapePoints = shape.points.map(p => ({
x: bounds.x + p.x,
y: bounds.y + p.y
}));
// Check if we need to account for expansion when removing
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
// Create a removal mask canvas - always hard-edged to ensure complete removal
let removalMaskCanvas;
// Create a temporary canvas large enough to contain the shape and any expansion
const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0));
const tempCanvasWidth = bounds.width + (maxExpansion * 2);
const tempCanvasHeight = bounds.height + (maxExpansion * 2);
const tempOffsetX = maxExpansion;
const tempOffsetY = maxExpansion;
// Adjust shape points for the temporary canvas
const tempShapePoints = worldShapePoints.map(p => ({
x: p.x - bounds.x + tempOffsetX,
y: p.y - bounds.y + tempOffsetY
}));
if (needsExpansion) {
// If expansion was active, remove the expanded area with a hard edge
removalMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight);
}
else {
// If no expansion, just remove the base shape with a hard edge
removalMaskCanvas = document.createElement('canvas');
removalMaskCanvas.width = tempCanvasWidth;
removalMaskCanvas.height = tempCanvasHeight;
const ctx = removalMaskCanvas.getContext('2d', { willReadFrequently: true });
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.moveTo(tempShapePoints[0].x, tempShapePoints[0].y);
for (let i = 1; i < tempShapePoints.length; i++) {
ctx.lineTo(tempShapePoints[i].x, tempShapePoints[i].y);
}
ctx.closePath();
ctx.fill('evenodd');
}
// Now remove the shape mask from the chunked system
this.removeMaskCanvasFromChunks(removalMaskCanvas, bounds.x - tempOffsetX, bounds.y - tempOffsetY);
// Update the active mask canvas to show the changes
this.updateActiveMaskCanvas();
if (this.onStateChange) {
this.onStateChange();
}
this.canvasInstance.render();
log.info(`Removed shape mask from chunks with expansion: ${needsExpansion}.`);
}
_createFeatheredMaskCanvas(points, featherRadius, width, height) {
// 1. Create a binary mask on a temporary canvas.
const binaryCanvas = document.createElement('canvas');
binaryCanvas.width = width;
binaryCanvas.height = height;
const binaryCtx = binaryCanvas.getContext('2d', { willReadFrequently: true });
binaryCtx.fillStyle = 'white';
binaryCtx.beginPath();
binaryCtx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
binaryCtx.lineTo(points[i].x, points[i].y);
}
binaryCtx.closePath();
binaryCtx.fill();
const maskImage = binaryCtx.getImageData(0, 0, width, height);
const binaryData = new Uint8Array(width * height);
for (let i = 0; i < binaryData.length; i++) {
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0; // 1 = inside, 0 = outside
}
// 2. Calculate the fast distance transform (from ImageAnalysis.ts approach).
const distanceMap = this._fastDistanceTransform(binaryData, width, height);
// Find the maximum distance to normalize
let maxDistance = 0;
for (let i = 0; i < distanceMap.length; i++) {
if (distanceMap[i] > maxDistance) {
maxDistance = distanceMap[i];
}
}
// 3. Create the final output canvas with the complete mask (solid + feather).
const outputCanvas = document.createElement('canvas');
outputCanvas.width = width;
outputCanvas.height = height;
const outputCtx = outputCanvas.getContext('2d', { willReadFrequently: true });
const outputData = outputCtx.createImageData(width, height);
// Use featherRadius as the threshold for the gradient
const threshold = Math.min(featherRadius, maxDistance);
for (let i = 0; i < distanceMap.length; i++) {
const distance = distanceMap[i];
const originalAlpha = maskImage.data[i * 4 + 3];
if (originalAlpha === 0) {
// Transparent pixels remain transparent
outputData.data[i * 4] = 255;
outputData.data[i * 4 + 1] = 255;
outputData.data[i * 4 + 2] = 255;
outputData.data[i * 4 + 3] = 0;
}
else if (distance <= threshold) {
// Edge area - apply gradient alpha (from edge inward)
const gradientValue = distance / threshold;
const alphaValue = Math.floor(gradientValue * 255);
outputData.data[i * 4] = 255;
outputData.data[i * 4 + 1] = 255;
outputData.data[i * 4 + 2] = 255;
outputData.data[i * 4 + 3] = alphaValue;
}
else {
// Inner area - full alpha (no blending effect)
outputData.data[i * 4] = 255;
outputData.data[i * 4 + 1] = 255;
outputData.data[i * 4 + 2] = 255;
outputData.data[i * 4 + 3] = 255;
}
}
outputCtx.putImageData(outputData, 0, 0);
return outputCanvas;
}
/**
* Fast distance transform using the simple two-pass algorithm from ImageAnalysis.ts
* Much faster than the complex Felzenszwalb algorithm
*/
_fastDistanceTransform(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;
}
/**
* Creates an expanded/contracted mask canvas using simple morphological operations
* This gives SHARP edges without smoothing, unlike distance transform
*/
_createExpandedMaskCanvas(points, expansionValue, width, height) {
// 1. Create a binary mask on a temporary canvas.
const binaryCanvas = document.createElement('canvas');
binaryCanvas.width = width;
binaryCanvas.height = height;
const binaryCtx = binaryCanvas.getContext('2d', { willReadFrequently: true });
binaryCtx.fillStyle = 'white';
binaryCtx.beginPath();
binaryCtx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
binaryCtx.lineTo(points[i].x, points[i].y);
}
binaryCtx.closePath();
binaryCtx.fill('evenodd'); // Use evenodd to handle holes correctly
const maskImage = binaryCtx.getImageData(0, 0, width, height);
const binaryData = new Uint8Array(width * height);
for (let i = 0; i < binaryData.length; i++) {
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0; // 1 = inside, 0 = outside
}
// 2. Apply fast morphological operations for sharp edges
let resultMask;
const absExpansionValue = Math.abs(expansionValue);
if (expansionValue >= 0) {
// EXPANSION: Use new fast dilation algorithm
resultMask = this._fastDilateDT(binaryData, width, height, absExpansionValue);
}
else {
// CONTRACTION: Use new fast erosion algorithm
resultMask = this._fastErodeDT(binaryData, width, height, absExpansionValue);
}
// 3. Create the final output canvas with sharp edges
const outputCanvas = document.createElement('canvas');
outputCanvas.width = width;
outputCanvas.height = height;
const outputCtx = outputCanvas.getContext('2d', { willReadFrequently: true });
const outputData = outputCtx.createImageData(width, height);
for (let i = 0; i < resultMask.length; i++) {
const alpha = resultMask[i] === 1 ? 255 : 0; // Sharp binary mask - no smoothing
outputData.data[i * 4] = 255; // R
outputData.data[i * 4 + 1] = 255; // G
outputData.data[i * 4 + 2] = 255; // B
outputData.data[i * 4 + 3] = alpha; // A - sharp edges
}
outputCtx.putImageData(outputData, 0, 0);
return outputCanvas;
}
/**
* Creates a feathered mask from existing ImageData (used when combining expansion + feather)
*/
_createFeatheredMaskFromImageData(imageData, featherRadius, width, height) {
const data = imageData.data;
const binaryData = new Uint8Array(width * height);
// Convert ImageData to binary mask
for (let i = 0; i < width * height; i++) {
binaryData[i] = data[i * 4 + 3] > 0 ? 1 : 0; // 1 = inside, 0 = outside
}
// Calculate the fast distance transform
const distanceMap = this._fastDistanceTransform(binaryData, width, height);
// Find the maximum distance to normalize
let maxDistance = 0;
for (let i = 0; i < distanceMap.length; i++) {
if (distanceMap[i] > maxDistance) {
maxDistance = distanceMap[i];
}
}
// Create the final output canvas with feathering applied
const outputCanvas = document.createElement('canvas');
outputCanvas.width = width;
outputCanvas.height = height;
const outputCtx = outputCanvas.getContext('2d', { willReadFrequently: true });
const outputData = outputCtx.createImageData(width, height);
// Use featherRadius as the threshold for the gradient
const threshold = Math.min(featherRadius, maxDistance);
for (let i = 0; i < distanceMap.length; i++) {
const distance = distanceMap[i];
const originalAlpha = data[i * 4 + 3];
if (originalAlpha === 0) {
// Transparent pixels remain transparent
outputData.data[i * 4] = 255;
outputData.data[i * 4 + 1] = 255;
outputData.data[i * 4 + 2] = 255;
outputData.data[i * 4 + 3] = 0;
}
else if (distance <= threshold) {
// Edge area - apply gradient alpha (from edge inward)
const gradientValue = distance / threshold;
const alphaValue = Math.floor(gradientValue * 255);
outputData.data[i * 4] = 255;
outputData.data[i * 4 + 1] = 255;
outputData.data[i * 4 + 2] = 255;
outputData.data[i * 4 + 3] = alphaValue;
}
else {
// Inner area - full alpha (no blending effect)
outputData.data[i * 4] = 255;
outputData.data[i * 4 + 1] = 255;
outputData.data[i * 4 + 2] = 255;
outputData.data[i * 4 + 3] = 255;
}
}
outputCtx.putImageData(outputData, 0, 0);
return outputCanvas;
}
}