From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- .../web/src/services/renderer/nodes/visual-node.ts | 225 ++++++++++++++++----- 1 file changed, 179 insertions(+), 46 deletions(-) (limited to 'apps/web/src/services/renderer/nodes/visual-node.ts') diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 6c50aa12..e0f87c52 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -1,10 +1,11 @@ import type { CanvasRenderer } from "../canvas-renderer"; import { createOffscreenCanvas } from "../canvas-utils"; import { BaseNode } from "./base-node"; -import type { Effect } from "@/types/effects"; -import type { BlendMode } from "@/types/rendering"; -import type { Transform } from "@/types/timeline"; -import type { ElementAnimations } from "@/types/animation"; +import type { Effect } from "@/lib/effects/types"; +import type { Mask } from "@/lib/masks/types"; +import type { BlendMode, Transform } from "@/lib/rendering"; +import type { ElementAnimations } from "@/lib/animation/types"; +import type { RetimeConfig } from "@/lib/timeline"; import { getElementLocalTime, resolveOpacityAtTime, @@ -12,26 +13,38 @@ import { } from "@/lib/animation"; import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; -import { getEffect } from "@/lib/effects"; -import { webglEffectRenderer } from "../webgl-effect-renderer"; +import { effectsRegistry, resolveEffectPasses } from "@/lib/effects"; +import { masksRegistry } from "@/lib/masks"; +import { getSourceTimeAtClipTime } from "@/lib/retime"; +import { webglEffectRenderer } from "../webgl/webgl-effect-renderer"; +import { applyMaskFeather } from "../mask-feather"; export interface VisualNodeParams { duration: number; timeOffset: number; trimStart: number; trimEnd: number; + retime?: RetimeConfig; transform: Transform; animations?: ElementAnimations; opacity: number; blendMode?: BlendMode; effects?: Effect[]; + masks?: Mask[]; } export abstract class VisualNode< Params extends VisualNodeParams = VisualNodeParams, > extends BaseNode { protected getSourceLocalTime({ time }: { time: number }): number { - return time - this.params.timeOffset + this.params.trimStart; + const clipTime = time - this.params.timeOffset; + return ( + this.params.trimStart + + getSourceTimeAtClipTime({ + clipTime, + retime: this.params.retime, + }) + ); } protected getAnimationLocalTime({ time }: { time: number }): number { @@ -43,10 +56,10 @@ export abstract class VisualNode< } protected isInRange({ time }: { time: number }): boolean { - const localTime = this.getSourceLocalTime({ time }); + const localTime = time - this.params.timeOffset; return ( - localTime >= this.params.trimStart - TIME_EPSILON_SECONDS && - localTime < this.params.trimStart + this.params.duration + localTime >= -TIME_EPSILON_SECONDS && + localTime < this.params.duration ); } @@ -65,7 +78,9 @@ export abstract class VisualNode< }): void { renderer.context.save(); - const animationLocalTime = this.getAnimationLocalTime({ time: timelineTime }); + const animationLocalTime = this.getAnimationLocalTime({ + time: timelineTime, + }); const transform = resolveTransformAtTime({ baseTransform: this.params.transform, animations: this.params.animations, @@ -80,10 +95,12 @@ export abstract class VisualNode< renderer.width / sourceWidth, renderer.height / sourceHeight, ); - const scaledWidth = sourceWidth * containScale * transform.scale; - const scaledHeight = sourceHeight * containScale * transform.scale; - const x = renderer.width / 2 + transform.position.x - scaledWidth / 2; - const y = renderer.height / 2 + transform.position.y - scaledHeight / 2; + const scaledWidth = sourceWidth * containScale * transform.scaleX; + const scaledHeight = sourceHeight * containScale * transform.scaleY; + const absWidth = Math.abs(scaledWidth); + const absHeight = Math.abs(scaledHeight); + const x = renderer.width / 2 + transform.position.x - absWidth / 2; + const y = renderer.height / 2 + transform.position.y - absHeight / 2; renderer.context.globalCompositeOperation = ( this.params.blendMode && this.params.blendMode !== "normal" @@ -92,71 +109,187 @@ export abstract class VisualNode< ) as GlobalCompositeOperation; renderer.context.globalAlpha = opacity; - if (transform.rotate !== 0) { - const centerX = x + scaledWidth / 2; - const centerY = y + scaledHeight / 2; + const flipX = scaledWidth < 0 ? -1 : 1; + const flipY = scaledHeight < 0 ? -1 : 1; + const needsTransform = transform.rotate !== 0 || flipX !== 1 || flipY !== 1; + + if (needsTransform) { + const centerX = x + absWidth / 2; + const centerY = y + absHeight / 2; renderer.context.translate(centerX, centerY); renderer.context.rotate((transform.rotate * Math.PI) / 180); + renderer.context.scale(flipX, flipY); renderer.context.translate(-centerX, -centerY); } const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? []; + const activeMasks = this.params.masks ?? []; + + if (activeMasks.length === 0 && enabledEffects.length === 0) { + renderer.context.drawImage(source, x, y, absWidth, absHeight); + renderer.context.restore(); + return; + } + + const currentResult = + enabledEffects.length > 0 + ? this.applyEffects({ + source, + effects: enabledEffects, + width: absWidth, + height: absHeight, + animationLocalTime, + }) + : source; - if (enabledEffects.length === 0) { - renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + if (activeMasks.length === 0) { + renderer.context.drawImage(currentResult, x, y, absWidth, absHeight); renderer.context.restore(); return; } const elementCanvas = createOffscreenCanvas({ - width: Math.round(scaledWidth), - height: Math.round(scaledHeight), + width: Math.round(absWidth), + height: Math.round(absHeight), }); const elementCtx = elementCanvas.getContext("2d") as | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null; if (!elementCtx) { - renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + renderer.context.drawImage(currentResult, x, y, absWidth, absHeight); renderer.context.restore(); return; } - elementCtx.drawImage(source, 0, 0, scaledWidth, scaledHeight); + elementCtx.drawImage(currentResult, 0, 0, absWidth, absHeight); - let currentResult: CanvasImageSource = elementCanvas; + for (const mask of activeMasks) { + this.applyMask({ + mask, + elementCtx, + scaledWidth: absWidth, + scaledHeight: absHeight, + }); + } + + renderer.context.drawImage(elementCanvas, x, y, absWidth, absHeight); + renderer.context.restore(); + } - for (const effect of enabledEffects) { + private applyEffects({ + source, + effects, + width, + height, + animationLocalTime, + }: { + source: CanvasImageSource; + effects: Effect[]; + width: number; + height: number; + animationLocalTime: number; + }): CanvasImageSource { + let current: CanvasImageSource = source; + for (const effect of effects) { const resolvedParams = resolveEffectParamsAtTime({ effect, animations: this.params.animations, localTime: animationLocalTime, }); - const definition = getEffect({ effectType: effect.type }); - const passes = definition.renderer.passes.map((pass) => ({ - fragmentShader: pass.fragmentShader, - uniforms: pass.uniforms({ - effectParams: resolvedParams, - width: scaledWidth, - height: scaledHeight, - }), - })); - currentResult = webglEffectRenderer.applyEffect({ - source: currentResult, + const definition = effectsRegistry.get(effect.type); + const passes = resolveEffectPasses({ + definition, + effectParams: resolvedParams, + width, + height, + }); + current = webglEffectRenderer.applyEffect({ + source: current, + width: Math.round(width), + height: Math.round(height), + passes, + }); + } + return current; + } + + private applyMask({ + mask, + elementCtx, + scaledWidth, + scaledHeight, + }: { + mask: Mask; + elementCtx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + scaledWidth: number; + scaledHeight: number; + }): void { + const definition = masksRegistry.get(mask.type); + const { feather, inverted } = mask.params; + + const maskCanvas = createOffscreenCanvas({ + width: Math.round(scaledWidth), + height: Math.round(scaledHeight), + }); + const maskCtx = maskCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!maskCtx) return; + + maskCtx.clearRect(0, 0, scaledWidth, scaledHeight); + + let maskResult: CanvasImageSource = maskCanvas; + let path: Path2D | null = null; + + if (feather > 0 && definition.renderer.renderMask) { + // Bypasses JFA — avoids the two-sided distance artifact where strips + // near the canvas edge appear semi-transparent. + definition.renderer.renderMask({ + resolvedParams: mask.params, + ctx: maskCtx, width: Math.round(scaledWidth), height: Math.round(scaledHeight), - passes, + feather, + }); + } else { + path = definition.renderer.buildPath({ + resolvedParams: mask.params, + width: scaledWidth, + height: scaledHeight, }); + maskCtx.fillStyle = "white"; + maskCtx.fill(path); + + if (feather > 0) { + maskResult = applyMaskFeather({ + maskCanvas, + width: Math.round(scaledWidth), + height: Math.round(scaledHeight), + feather, + }); + } } - renderer.context.drawImage( - currentResult, - x, - y, - scaledWidth, - scaledHeight, - ); - renderer.context.restore(); + elementCtx.globalCompositeOperation = inverted + ? "destination-out" + : "destination-in"; + elementCtx.drawImage(maskResult, 0, 0, scaledWidth, scaledHeight); + elementCtx.globalCompositeOperation = "source-over"; + + const strokePath = + definition.renderer.buildStrokePath?.({ + resolvedParams: mask.params, + width: scaledWidth, + height: scaledHeight, + }) ?? path; + + if (mask.params.strokeWidth > 0 && strokePath) { + elementCtx.strokeStyle = mask.params.strokeColor; + elementCtx.lineWidth = mask.params.strokeWidth; + elementCtx.stroke(strokePath); + } } } -- cgit v1.3.1