refactor: share canvas output rendering primitives

This commit is contained in:
Dariusz L
2026-08-13 07:24:29 +02:00
parent ec313f1ce6
commit b509482eb1
6 changed files with 156 additions and 113 deletions
+6 -18
View File
@@ -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
+33 -34
View File
@@ -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) {
+14 -18
View File
@@ -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;
+70 -41
View File
@@ -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<void> {
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<Blob | null> {
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();
@@ -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\(\)/);
});
+3 -2
View File
@@ -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/);