feat: masks, properties refactor, shaders, storage migrations, and more

This commit is contained in:
Maze Winther
2026-03-29 15:48:22 +02:00
parent 39ea298a9c
commit 8db3bead13
690 changed files with 35618 additions and 7337 deletions
@@ -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<Params> {
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 (enabledEffects.length === 0) {
renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
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 (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,
});
}
for (const effect of enabledEffects) {
renderer.context.drawImage(elementCanvas, x, y, absWidth, absHeight);
renderer.context.restore();
}
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,
width: Math.round(scaledWidth),
height: Math.round(scaledHeight),
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;
}
renderer.context.drawImage(
currentResult,
x,
y,
scaledWidth,
scaledHeight,
);
renderer.context.restore();
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),
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,
});
}
}
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);
}
}
}