445 lines
12 KiB
TypeScript
445 lines
12 KiB
TypeScript
|
|
/**
|
||
|
|
* Layer Effects Rendering Engine
|
||
|
|
* Applies non-destructive visual effects to layer canvases
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type {
|
||
|
|
LayerEffect,
|
||
|
|
LayerEffectsConfig,
|
||
|
|
DropShadowEffect,
|
||
|
|
OuterGlowEffect,
|
||
|
|
InnerShadowEffect,
|
||
|
|
InnerGlowEffect,
|
||
|
|
StrokeEffect,
|
||
|
|
ColorOverlayEffect,
|
||
|
|
BevelEffect,
|
||
|
|
} from '@/types/layer-effects';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply all enabled effects to a layer canvas
|
||
|
|
* Returns a new canvas with effects applied (non-destructive)
|
||
|
|
*/
|
||
|
|
export function applyLayerEffects(
|
||
|
|
sourceCanvas: HTMLCanvasElement,
|
||
|
|
effectsConfig: LayerEffectsConfig | undefined
|
||
|
|
): HTMLCanvasElement {
|
||
|
|
if (!effectsConfig || !effectsConfig.enabled || effectsConfig.effects.length === 0) {
|
||
|
|
return sourceCanvas;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Filter to only enabled effects
|
||
|
|
const activeEffects = effectsConfig.effects.filter((e) => e.enabled);
|
||
|
|
if (activeEffects.length === 0) {
|
||
|
|
return sourceCanvas;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create output canvas with extra padding for effects like shadows and glows
|
||
|
|
const padding = calculateRequiredPadding(activeEffects);
|
||
|
|
const outputCanvas = document.createElement('canvas');
|
||
|
|
outputCanvas.width = sourceCanvas.width + padding.left + padding.right;
|
||
|
|
outputCanvas.height = sourceCanvas.height + padding.top + padding.bottom;
|
||
|
|
|
||
|
|
const ctx = outputCanvas.getContext('2d', { willReadFrequently: true });
|
||
|
|
if (!ctx) return sourceCanvas;
|
||
|
|
|
||
|
|
// Effects are applied in a specific order for best visual results
|
||
|
|
// Order: Background effects → Layer content → Overlay effects
|
||
|
|
|
||
|
|
ctx.save();
|
||
|
|
ctx.translate(padding.left, padding.top);
|
||
|
|
|
||
|
|
// Step 1: Apply effects that go behind the layer (Drop Shadow, Outer Glow)
|
||
|
|
applyBackgroundEffects(ctx, sourceCanvas, activeEffects, effectsConfig);
|
||
|
|
|
||
|
|
// Step 2: Draw the original layer content
|
||
|
|
ctx.globalCompositeOperation = 'source-over';
|
||
|
|
ctx.drawImage(sourceCanvas, 0, 0);
|
||
|
|
|
||
|
|
// Step 3: Apply effects that modify the layer (Inner Shadow, Inner Glow, Stroke)
|
||
|
|
applyModifyingEffects(ctx, sourceCanvas, activeEffects, effectsConfig);
|
||
|
|
|
||
|
|
// Step 4: Apply overlay effects (Color Overlay, Gradient Overlay, Pattern Overlay)
|
||
|
|
applyOverlayEffects(ctx, sourceCanvas, activeEffects);
|
||
|
|
|
||
|
|
ctx.restore();
|
||
|
|
|
||
|
|
return outputCanvas;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate required padding for effects
|
||
|
|
*/
|
||
|
|
function calculateRequiredPadding(effects: LayerEffect[]): {
|
||
|
|
top: number;
|
||
|
|
right: number;
|
||
|
|
bottom: number;
|
||
|
|
left: number;
|
||
|
|
} {
|
||
|
|
let maxPadding = 0;
|
||
|
|
|
||
|
|
effects.forEach((effect) => {
|
||
|
|
if (!effect.enabled) return;
|
||
|
|
|
||
|
|
switch (effect.type) {
|
||
|
|
case 'dropShadow': {
|
||
|
|
const shadow = effect as DropShadowEffect;
|
||
|
|
const maxBlur = shadow.size + shadow.distance;
|
||
|
|
maxPadding = Math.max(maxPadding, maxBlur);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'outerGlow': {
|
||
|
|
const glow = effect as OuterGlowEffect;
|
||
|
|
maxPadding = Math.max(maxPadding, glow.size);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'stroke': {
|
||
|
|
const stroke = effect as StrokeEffect;
|
||
|
|
if (stroke.position === 'outside') {
|
||
|
|
maxPadding = Math.max(maxPadding, stroke.size);
|
||
|
|
} else if (stroke.position === 'center') {
|
||
|
|
maxPadding = Math.max(maxPadding, stroke.size / 2);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
top: Math.ceil(maxPadding),
|
||
|
|
right: Math.ceil(maxPadding),
|
||
|
|
bottom: Math.ceil(maxPadding),
|
||
|
|
left: Math.ceil(maxPadding),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply background effects (Drop Shadow, Outer Glow)
|
||
|
|
*/
|
||
|
|
function applyBackgroundEffects(
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
sourceCanvas: HTMLCanvasElement,
|
||
|
|
effects: LayerEffect[],
|
||
|
|
config: LayerEffectsConfig
|
||
|
|
): void {
|
||
|
|
effects.forEach((effect) => {
|
||
|
|
switch (effect.type) {
|
||
|
|
case 'dropShadow':
|
||
|
|
applyDropShadow(ctx, sourceCanvas, effect as DropShadowEffect, config);
|
||
|
|
break;
|
||
|
|
case 'outerGlow':
|
||
|
|
applyOuterGlow(ctx, sourceCanvas, effect as OuterGlowEffect);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply modifying effects (Inner Shadow, Inner Glow, Stroke)
|
||
|
|
*/
|
||
|
|
function applyModifyingEffects(
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
sourceCanvas: HTMLCanvasElement,
|
||
|
|
effects: LayerEffect[],
|
||
|
|
config: LayerEffectsConfig
|
||
|
|
): void {
|
||
|
|
effects.forEach((effect) => {
|
||
|
|
switch (effect.type) {
|
||
|
|
case 'innerShadow':
|
||
|
|
applyInnerShadow(ctx, sourceCanvas, effect as InnerShadowEffect, config);
|
||
|
|
break;
|
||
|
|
case 'innerGlow':
|
||
|
|
applyInnerGlow(ctx, sourceCanvas, effect as InnerGlowEffect);
|
||
|
|
break;
|
||
|
|
case 'stroke':
|
||
|
|
applyStroke(ctx, sourceCanvas, effect as StrokeEffect);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply overlay effects (Color Overlay, etc.)
|
||
|
|
*/
|
||
|
|
function applyOverlayEffects(
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
sourceCanvas: HTMLCanvasElement,
|
||
|
|
effects: LayerEffect[]
|
||
|
|
): void {
|
||
|
|
effects.forEach((effect) => {
|
||
|
|
switch (effect.type) {
|
||
|
|
case 'colorOverlay':
|
||
|
|
applyColorOverlay(ctx, sourceCanvas, effect as ColorOverlayEffect);
|
||
|
|
break;
|
||
|
|
// TODO: Gradient Overlay, Pattern Overlay
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply Drop Shadow Effect
|
||
|
|
*/
|
||
|
|
function applyDropShadow(
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
sourceCanvas: HTMLCanvasElement,
|
||
|
|
effect: DropShadowEffect,
|
||
|
|
config: LayerEffectsConfig
|
||
|
|
): void {
|
||
|
|
const angle = effect.useGlobalLight ? config.globalLightAngle : effect.angle;
|
||
|
|
const angleRad = (angle * Math.PI) / 180;
|
||
|
|
|
||
|
|
const offsetX = Math.cos(angleRad) * effect.distance;
|
||
|
|
const offsetY = Math.sin(angleRad) * effect.distance;
|
||
|
|
|
||
|
|
ctx.save();
|
||
|
|
ctx.globalAlpha = effect.opacity;
|
||
|
|
ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation;
|
||
|
|
|
||
|
|
// Apply shadow
|
||
|
|
ctx.shadowColor = effect.color;
|
||
|
|
ctx.shadowBlur = effect.size;
|
||
|
|
ctx.shadowOffsetX = offsetX;
|
||
|
|
ctx.shadowOffsetY = offsetY;
|
||
|
|
|
||
|
|
// Draw source as shadow
|
||
|
|
ctx.drawImage(sourceCanvas, 0, 0);
|
||
|
|
|
||
|
|
ctx.restore();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply Outer Glow Effect
|
||
|
|
*/
|
||
|
|
function applyOuterGlow(
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
sourceCanvas: HTMLCanvasElement,
|
||
|
|
effect: OuterGlowEffect
|
||
|
|
): void {
|
||
|
|
ctx.save();
|
||
|
|
ctx.globalAlpha = effect.opacity;
|
||
|
|
ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation;
|
||
|
|
|
||
|
|
// Create glow using shadow
|
||
|
|
ctx.shadowColor = effect.color;
|
||
|
|
ctx.shadowBlur = effect.size;
|
||
|
|
ctx.shadowOffsetX = 0;
|
||
|
|
ctx.shadowOffsetY = 0;
|
||
|
|
|
||
|
|
// Draw multiple times for stronger glow
|
||
|
|
const iterations = Math.ceil(effect.spread / 20) + 1;
|
||
|
|
for (let i = 0; i < iterations; i++) {
|
||
|
|
ctx.drawImage(sourceCanvas, 0, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx.restore();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply Inner Shadow Effect
|
||
|
|
*/
|
||
|
|
function applyInnerShadow(
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
sourceCanvas: HTMLCanvasElement,
|
||
|
|
effect: InnerShadowEffect,
|
||
|
|
config: LayerEffectsConfig
|
||
|
|
): void {
|
||
|
|
const angle = effect.useGlobalLight ? config.globalLightAngle : effect.angle;
|
||
|
|
const angleRad = (angle * Math.PI) / 180;
|
||
|
|
|
||
|
|
const offsetX = Math.cos(angleRad) * effect.distance;
|
||
|
|
const offsetY = Math.sin(angleRad) * effect.distance;
|
||
|
|
|
||
|
|
// Inner shadow is complex - we need to invert the layer, apply shadow, then comp it back
|
||
|
|
const tempCanvas = document.createElement('canvas');
|
||
|
|
tempCanvas.width = sourceCanvas.width;
|
||
|
|
tempCanvas.height = sourceCanvas.height;
|
||
|
|
const tempCtx = tempCanvas.getContext('2d');
|
||
|
|
if (!tempCtx) return;
|
||
|
|
|
||
|
|
// Step 1: Create inverted mask
|
||
|
|
tempCtx.fillStyle = 'white';
|
||
|
|
tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
|
||
|
|
tempCtx.globalCompositeOperation = 'destination-out';
|
||
|
|
tempCtx.drawImage(sourceCanvas, 0, 0);
|
||
|
|
|
||
|
|
// Step 2: Apply shadow to inverted mask
|
||
|
|
tempCtx.globalCompositeOperation = 'source-over';
|
||
|
|
tempCtx.shadowColor = effect.color;
|
||
|
|
tempCtx.shadowBlur = effect.size;
|
||
|
|
tempCtx.shadowOffsetX = -offsetX;
|
||
|
|
tempCtx.shadowOffsetY = -offsetY;
|
||
|
|
tempCtx.drawImage(tempCanvas, 0, 0);
|
||
|
|
|
||
|
|
// Step 3: Clip to original layer shape
|
||
|
|
ctx.save();
|
||
|
|
ctx.globalAlpha = effect.opacity;
|
||
|
|
ctx.globalCompositeOperation = 'source-atop';
|
||
|
|
ctx.drawImage(tempCanvas, 0, 0);
|
||
|
|
ctx.restore();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply Inner Glow Effect
|
||
|
|
*/
|
||
|
|
function applyInnerGlow(
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
sourceCanvas: HTMLCanvasElement,
|
||
|
|
effect: InnerGlowEffect
|
||
|
|
): void {
|
||
|
|
// Similar to inner shadow but without offset
|
||
|
|
const tempCanvas = document.createElement('canvas');
|
||
|
|
tempCanvas.width = sourceCanvas.width;
|
||
|
|
tempCanvas.height = sourceCanvas.height;
|
||
|
|
const tempCtx = tempCanvas.getContext('2d');
|
||
|
|
if (!tempCtx) return;
|
||
|
|
|
||
|
|
// Create inverted mask
|
||
|
|
tempCtx.fillStyle = 'white';
|
||
|
|
tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
|
||
|
|
tempCtx.globalCompositeOperation = 'destination-out';
|
||
|
|
tempCtx.drawImage(sourceCanvas, 0, 0);
|
||
|
|
|
||
|
|
// Apply glow
|
||
|
|
tempCtx.globalCompositeOperation = 'source-over';
|
||
|
|
tempCtx.shadowColor = effect.color;
|
||
|
|
tempCtx.shadowBlur = effect.size;
|
||
|
|
tempCtx.shadowOffsetX = 0;
|
||
|
|
tempCtx.shadowOffsetY = 0;
|
||
|
|
|
||
|
|
const iterations = Math.ceil(effect.choke / 20) + 1;
|
||
|
|
for (let i = 0; i < iterations; i++) {
|
||
|
|
tempCtx.drawImage(tempCanvas, 0, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clip to original layer
|
||
|
|
ctx.save();
|
||
|
|
ctx.globalAlpha = effect.opacity;
|
||
|
|
ctx.globalCompositeOperation = 'source-atop';
|
||
|
|
ctx.drawImage(tempCanvas, 0, 0);
|
||
|
|
ctx.restore();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply Stroke Effect
|
||
|
|
*/
|
||
|
|
function applyStroke(
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
sourceCanvas: HTMLCanvasElement,
|
||
|
|
effect: StrokeEffect
|
||
|
|
): void {
|
||
|
|
ctx.save();
|
||
|
|
ctx.globalAlpha = effect.opacity;
|
||
|
|
ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation;
|
||
|
|
|
||
|
|
// For now, only implement solid color stroke
|
||
|
|
if (effect.fillType === 'color') {
|
||
|
|
ctx.strokeStyle = effect.color;
|
||
|
|
ctx.lineWidth = effect.size;
|
||
|
|
|
||
|
|
// Get the outline of the layer
|
||
|
|
const imageData = ctx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height);
|
||
|
|
const outline = getLayerOutline(imageData);
|
||
|
|
|
||
|
|
// Draw the stroke
|
||
|
|
ctx.beginPath();
|
||
|
|
outline.forEach((point, index) => {
|
||
|
|
if (index === 0) {
|
||
|
|
ctx.moveTo(point.x, point.y);
|
||
|
|
} else {
|
||
|
|
ctx.lineTo(point.x, point.y);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
ctx.closePath();
|
||
|
|
ctx.stroke();
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx.restore();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply Color Overlay Effect
|
||
|
|
*/
|
||
|
|
function applyColorOverlay(
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
sourceCanvas: HTMLCanvasElement,
|
||
|
|
effect: ColorOverlayEffect
|
||
|
|
): void {
|
||
|
|
ctx.save();
|
||
|
|
ctx.globalAlpha = effect.opacity;
|
||
|
|
ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation;
|
||
|
|
|
||
|
|
// Fill with color, clipped to layer shape
|
||
|
|
ctx.fillStyle = effect.color;
|
||
|
|
ctx.fillRect(0, 0, sourceCanvas.width, sourceCanvas.height);
|
||
|
|
|
||
|
|
ctx.restore();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get outline points of a layer (for stroke effect)
|
||
|
|
* This is a simplified version - proper implementation would use edge detection
|
||
|
|
*/
|
||
|
|
function getLayerOutline(imageData: ImageData): Array<{ x: number; y: number }> {
|
||
|
|
const points: Array<{ x: number; y: number }> = [];
|
||
|
|
const width = imageData.width;
|
||
|
|
const height = imageData.height;
|
||
|
|
const data = imageData.data;
|
||
|
|
|
||
|
|
// Simple edge detection - find pixels with alpha > 0 next to alpha === 0
|
||
|
|
for (let y = 0; y < height; y++) {
|
||
|
|
for (let x = 0; x < width; x++) {
|
||
|
|
const idx = (y * width + x) * 4;
|
||
|
|
const alpha = data[idx + 3];
|
||
|
|
|
||
|
|
if (alpha > 0) {
|
||
|
|
// Check if this is an edge pixel
|
||
|
|
const isEdge =
|
||
|
|
x === 0 ||
|
||
|
|
x === width - 1 ||
|
||
|
|
y === 0 ||
|
||
|
|
y === height - 1 ||
|
||
|
|
data[((y - 1) * width + x) * 4 + 3] === 0 ||
|
||
|
|
data[((y + 1) * width + x) * 4 + 3] === 0 ||
|
||
|
|
data[(y * width + (x - 1)) * 4 + 3] === 0 ||
|
||
|
|
data[(y * width + (x + 1)) * 4 + 3] === 0;
|
||
|
|
|
||
|
|
if (isEdge) {
|
||
|
|
points.push({ x, y });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return points;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if a layer has any active effects
|
||
|
|
*/
|
||
|
|
export function hasActiveEffects(effectsConfig: LayerEffectsConfig | undefined): boolean {
|
||
|
|
if (!effectsConfig || !effectsConfig.enabled) return false;
|
||
|
|
return effectsConfig.effects.some((e) => e.enabled);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the effective bounds of a layer with effects applied
|
||
|
|
* This is used to know how much larger the layer will be with effects
|
||
|
|
*/
|
||
|
|
export function getEffectBounds(
|
||
|
|
layerWidth: number,
|
||
|
|
layerHeight: number,
|
||
|
|
effectsConfig: LayerEffectsConfig | undefined
|
||
|
|
): { width: number; height: number; offsetX: number; offsetY: number } {
|
||
|
|
if (!effectsConfig || !effectsConfig.enabled) {
|
||
|
|
return { width: layerWidth, height: layerHeight, offsetX: 0, offsetY: 0 };
|
||
|
|
}
|
||
|
|
|
||
|
|
const padding = calculateRequiredPadding(effectsConfig.effects);
|
||
|
|
|
||
|
|
return {
|
||
|
|
width: layerWidth + padding.left + padding.right,
|
||
|
|
height: layerHeight + padding.top + padding.bottom,
|
||
|
|
offsetX: padding.left,
|
||
|
|
offsetY: padding.top,
|
||
|
|
};
|
||
|
|
}
|