diff --git a/js/CanvasIO.js b/js/CanvasIO.js index 86fde89..948dffd 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -3,7 +3,6 @@ import { createModuleLogger } from "./log_system/log_funcs.js"; import { showErrorNotification } from "./utils/NotificationUtils.js"; import { webSocketManager } from "./utils/WebSocketManager.js"; import { scaleImageToFit, loadImage, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js"; -import { fillInverseAlphaMask } from "./utils/MaskPixelUtils.js"; const log = createModuleLogger('CanvasIO'); export class CanvasIO { constructor(canvas) { @@ -50,27 +49,16 @@ export class CanvasIO { await new Promise(resolve => setTimeout(resolve, delay)); } return new Promise((resolve) => { - const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height); - const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height); const originalShape = this.canvas.outputAreaShape; this.canvas.outputAreaShape = null; - const { canvas: visibilityCanvas, ctx: visibilityCtx } = createCanvas(this.canvas.width, this.canvas.height, '2d', { alpha: true }); - if (!visibilityCtx) - throw new Error("Could not create visibility context"); - if (!maskCtx) - throw new Error("Could not create mask context"); - if (!tempCtx) - throw new Error("Could not create temp context"); - maskCtx.fillStyle = '#ffffff'; - maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height); + const renderBounds = { x: 0, y: 0, width: this.canvas.width, height: this.canvas.height }; + const { canvas: tempCanvas } = this.canvas.canvasLayers.renderLayersToCanvas(renderBounds, this.canvas.layers, {}); + const { canvas: maskCanvas, ctx: maskCtx } = this.canvas.canvasLayers.renderLayerVisibilityMask(renderBounds, this.canvas.layers, { + maskContextOptions: {}, + visibilityContextOptions: { alpha: true } + }); log.debug(`Canvas contexts created, starting layer rendering`); - this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers); - this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers); log.debug(`Finished rendering layers`); - const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); - const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); - fillInverseAlphaMask(visibilityData, maskData); - maskCtx.putImageData(maskData, 0, 0); this.canvas.outputAreaShape = originalShape; // Use optimized getMaskForOutputArea() instead of getMask() for better performance // This only processes chunks that overlap with the output area diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index 861849b..1ad7c39 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -985,6 +985,30 @@ export class CanvasLayers { drawLayersToContext(ctx, layers, options = {}) { this._drawLayers(ctx, layers, options); } + renderLayersToCanvas(bounds, layers = this.canvas.layers, contextOptions = {}) { + const { canvas, ctx } = createCanvas(bounds.width, bounds.height, '2d', contextOptions); + if (!ctx) { + throw new Error("Could not create canvas context"); + } + ctx.translate(-bounds.x, -bounds.y); + this._drawLayers(ctx, layers); + return { canvas, ctx }; + } + renderLayerVisibilityMask(bounds, layers = this.canvas.layers, options = {}) { + const { maskContextOptions = { willReadFrequently: true }, visibilityContextOptions = { alpha: true } } = options; + const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(bounds.width, bounds.height, '2d', maskContextOptions); + if (!maskCtx) { + throw new Error("Could not create mask context"); + } + maskCtx.fillStyle = '#ffffff'; + maskCtx.fillRect(0, 0, bounds.width, bounds.height); + const { ctx: visibilityCtx } = this.renderLayersToCanvas(bounds, layers, visibilityContextOptions); + const visibilityData = visibilityCtx.getImageData(0, 0, bounds.width, bounds.height); + const maskData = maskCtx.getImageData(0, 0, bounds.width, bounds.height); + fillInverseAlphaMask(visibilityData, maskData); + maskCtx.putImageData(maskData, 0, 0); + return { canvas: maskCanvas, ctx: maskCtx }; + } async mirrorHorizontal() { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; @@ -1409,7 +1433,7 @@ export class CanvasLayers { */ async _generateCanvasBlob(options = {}) { const { layers = this.canvas.layers, useOutputBounds = true, applyMask = false, enableLogging = false, customBounds } = options; - return new Promise((resolve, reject) => { + return new Promise((resolve) => { let bounds; if (customBounds) { bounds = customBounds; @@ -1427,15 +1451,10 @@ export class CanvasLayers { } bounds = { x: layerBounds.x, y: layerBounds.y, 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(`Canvas Size: ${bounds.width}x${bounds.height}`); log.info(`Context Translation: translate(${-bounds.x}, ${-bounds.y})`); log.info(`Apply Mask: ${applyMask}`); // Log layer positions before rendering @@ -1449,9 +1468,7 @@ export class CanvasLayers { } }); } - // Renderuj fragment świata zdefiniowany przez bounds - tempCtx.translate(-bounds.x, -bounds.y); - this._drawLayers(tempCtx, layers); + const { canvas: tempCanvas, ctx: tempCtx } = this.renderLayersToCanvas(bounds, layers, { willReadFrequently: true }); // Aplikuj maskę jeśli wymagana if (applyMask) { const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); @@ -1515,32 +1532,14 @@ export class CanvasLayers { }); } async getFlattenedMaskAsBlob() { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { 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); - fillInverseAlphaMask(visibilityData, maskData); - maskCtx.putImageData(maskData, 0, 0); + log.info(`Mask Canvas Size: ${bounds.width}x${bounds.height}`); + const { canvas: maskCanvas, ctx: maskCtx } = this.renderLayerVisibilityMask(bounds, this.canvas.layers, { + maskContextOptions: { willReadFrequently: true }, + visibilityContextOptions: { alpha: true } + }); // Aplikuj maskę narzędzia jeśli istnieje - używaj zoptymalizowanej metody const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea(); if (toolMaskCanvas) { diff --git a/src/CanvasIO.ts b/src/CanvasIO.ts index 783e728..64bd48f 100644 --- a/src/CanvasIO.ts +++ b/src/CanvasIO.ts @@ -3,7 +3,6 @@ import { createModuleLogger } from "./log_system/log_funcs.js"; import { showErrorNotification } from "./utils/NotificationUtils.js"; import { webSocketManager } from "./utils/WebSocketManager.js"; import { scaleImageToFit, loadImage, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js"; -import { fillInverseAlphaMask } from "./utils/MaskPixelUtils.js"; import type { Canvas } from './Canvas'; import type { Layer, Shape } from './types'; @@ -61,29 +60,26 @@ export class CanvasIO { } return new Promise((resolve) => { - const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height); - const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height); - const originalShape = this.canvas.outputAreaShape; this.canvas.outputAreaShape = null; - const { canvas: visibilityCanvas, ctx: visibilityCtx } = createCanvas(this.canvas.width, this.canvas.height, '2d', { alpha: true }); - if (!visibilityCtx) throw new Error("Could not create visibility context"); - if (!maskCtx) throw new Error("Could not create mask context"); - if (!tempCtx) throw new Error("Could not create temp context"); - maskCtx.fillStyle = '#ffffff'; - maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height); + const renderBounds = { x: 0, y: 0, width: this.canvas.width, height: this.canvas.height }; + const { canvas: tempCanvas } = this.canvas.canvasLayers.renderLayersToCanvas( + renderBounds, + this.canvas.layers, + {} + ); + const { canvas: maskCanvas, ctx: maskCtx } = this.canvas.canvasLayers.renderLayerVisibilityMask( + renderBounds, + this.canvas.layers, + { + maskContextOptions: {}, + visibilityContextOptions: { alpha: true } + } + ); log.debug(`Canvas contexts created, starting layer rendering`); - - this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers); - this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers); log.debug(`Finished rendering layers`); - const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); - const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); - fillInverseAlphaMask(visibilityData, maskData); - - maskCtx.putImageData(maskData, 0, 0); this.canvas.outputAreaShape = originalShape; diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts index beed5e5..d08eeaf 100644 --- a/src/CanvasLayers.ts +++ b/src/CanvasLayers.ts @@ -1139,6 +1139,59 @@ export class CanvasLayers { this._drawLayers(ctx, layers, options); } + public renderLayersToCanvas( + bounds: { x: number, y: number, width: number, height: number }, + layers: Layer[] = this.canvas.layers, + contextOptions: CanvasRenderingContext2DSettings = {} + ): { canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D } { + const { canvas, ctx } = createCanvas(bounds.width, bounds.height, '2d', contextOptions); + if (!ctx) { + throw new Error("Could not create canvas context"); + } + + ctx.translate(-bounds.x, -bounds.y); + this._drawLayers(ctx, layers); + return { canvas, ctx }; + } + + public renderLayerVisibilityMask( + bounds: { x: number, y: number, width: number, height: number }, + layers: Layer[] = this.canvas.layers, + options: { + maskContextOptions?: CanvasRenderingContext2DSettings; + visibilityContextOptions?: CanvasRenderingContext2DSettings; + } = {} + ): { canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D } { + const { + maskContextOptions = { willReadFrequently: true }, + visibilityContextOptions = { alpha: true } + } = options; + const { canvas: maskCanvas, ctx: maskCtx } = createCanvas( + bounds.width, + bounds.height, + '2d', + maskContextOptions + ); + if (!maskCtx) { + throw new Error("Could not create mask context"); + } + + maskCtx.fillStyle = '#ffffff'; + maskCtx.fillRect(0, 0, bounds.width, bounds.height); + + const { ctx: visibilityCtx } = this.renderLayersToCanvas( + bounds, + layers, + visibilityContextOptions + ); + const visibilityData = visibilityCtx.getImageData(0, 0, bounds.width, bounds.height); + const maskData = maskCtx.getImageData(0, 0, bounds.width, bounds.height); + fillInverseAlphaMask(visibilityData, maskData); + maskCtx.putImageData(maskData, 0, 0); + + return { canvas: maskCanvas, ctx: maskCtx }; + } + async mirrorHorizontal(): Promise { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { @@ -1656,7 +1709,7 @@ export class CanvasLayers { customBounds } = options; - return new Promise((resolve, reject) => { + return new Promise((resolve) => { let bounds: { x: number, y: number, width: number, height: number }; if (customBounds) { @@ -1678,16 +1731,10 @@ export class CanvasLayers { bounds = { x: layerBounds.x, y: layerBounds.y, 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(`Canvas Size: ${bounds.width}x${bounds.height}`); log.info(`Context Translation: translate(${-bounds.x}, ${-bounds.y})`); log.info(`Apply Mask: ${applyMask}`); @@ -1703,9 +1750,11 @@ export class CanvasLayers { }); } - // Renderuj fragment świata zdefiniowany przez bounds - tempCtx.translate(-bounds.x, -bounds.y); - this._drawLayers(tempCtx, layers); + const { canvas: tempCanvas, ctx: tempCtx } = this.renderLayersToCanvas( + bounds, + layers, + { willReadFrequently: true } + ); // Aplikuj maskę jeśli wymagana if (applyMask) { @@ -1778,38 +1827,18 @@ export class CanvasLayers { } async getFlattenedMaskAsBlob(): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { 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); - fillInverseAlphaMask(visibilityData, maskData); - maskCtx.putImageData(maskData, 0, 0); + log.info(`Mask Canvas Size: ${bounds.width}x${bounds.height}`); + const { canvas: maskCanvas, ctx: maskCtx } = this.renderLayerVisibilityMask( + bounds, + this.canvas.layers, + { + maskContextOptions: { willReadFrequently: true }, + visibilityContextOptions: { alpha: true } + } + ); // Aplikuj maskę narzędzia jeśli istnieje - używaj zoptymalizowanej metody const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea(); diff --git a/tests/test_canvas_renderer_characterization.mjs b/tests/test_canvas_renderer_characterization.mjs new file mode 100644 index 0000000..7522b2e --- /dev/null +++ b/tests/test_canvas_renderer_characterization.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const [canvasIOSource, canvasLayersSource] = await Promise.all([ + readFile(new URL('../src/CanvasIO.ts', import.meta.url), 'utf8'), + readFile(new URL('../src/CanvasLayers.ts', import.meta.url), 'utf8'), +]); + +test('output paths preserve their current render bounds and mask responsibilities', () => { + assert.match(canvasIOSource, /renderLayersToCanvas\(/); + assert.match(canvasIOSource, /renderLayerVisibilityMask\(/); + assert.match(canvasIOSource, /const renderBounds = \{ x: 0, y: 0, width: this\.canvas\.width, height: this\.canvas\.height \}/); + assert.match(canvasIOSource, /const bounds = this\.canvas\.outputAreaBounds/); + + assert.match(canvasLayersSource, /public renderLayersToCanvas\(/); + assert.match(canvasLayersSource, /public renderLayerVisibilityMask\(/); + assert.match(canvasLayersSource, /this\.renderLayersToCanvas\(/); + assert.doesNotMatch(canvasLayersSource, /this\._drawLayers\(tempCtx, layers\)/); + assert.doesNotMatch(canvasLayersSource, /visibilityCtx\.translate\(-bounds\.x, -bounds\.y\)/); + assert.match(canvasLayersSource, /maskCtx\.globalCompositeOperation = 'screen'/); +}); + +test('output rendering keeps persistence and transport in CanvasIO', () => { + assert.match(canvasIOSource, /async _performSave\(fileName: string, outputMode: string\)/); + assert.match(canvasIOSource, /tempCanvas\.toDataURL\('image\/png'\)/); + assert.match(canvasIOSource, /fetch\("\/upload\/image"/); + assert.match(canvasLayersSource, /async getFlattenedCanvasAsBlob\(\)/); + assert.match(canvasLayersSource, /async getFlattenedMaskAsBlob\(\)/); +}); diff --git a/tests/test_mask_pixel_characterization.mjs b/tests/test_mask_pixel_characterization.mjs index 2b457ee..7212497 100644 --- a/tests/test_mask_pixel_characterization.mjs +++ b/tests/test_mask_pixel_characterization.mjs @@ -333,9 +333,10 @@ test('CanvasIO and CanvasLayers preserve inverse-alpha mask generation', async ( const canvasIOSource = await readFile(new URL('../src/CanvasIO.ts', import.meta.url), 'utf8'); const canvasLayersSource = await readFile(new URL('../src/CanvasLayers.ts', import.meta.url), 'utf8'); - assert.match(canvasIOSource, /from "\.\/utils\/MaskPixelUtils\.js"/); + assert.doesNotMatch(canvasIOSource, /from "\.\/utils\/MaskPixelUtils\.js"/); assert.match(canvasLayersSource, /from "\.\/utils\/MaskPixelUtils\.js"/); - assert.match(canvasIOSource, /fillInverseAlphaMask\(visibilityData, maskData\)/); + assert.match(canvasIOSource, /renderLayerVisibilityMask\(/); + assert.doesNotMatch(canvasIOSource, /fillInverseAlphaMask\(visibilityData, maskData\)/); assert.match(canvasLayersSource, /fillInverseAlphaMask\(visibilityData, maskData\)/); assert.doesNotMatch(canvasIOSource, /const maskValue = 255 - alpha/); assert.doesNotMatch(canvasLayersSource, /const maskValue = 255 - alpha/);