295 lines
7.9 KiB
TypeScript
295 lines
7.9 KiB
TypeScript
import type { CanvasRenderer } from "../canvas-renderer";
|
|
import { createOffscreenCanvas } from "../canvas-utils";
|
|
import { BaseNode } from "./base-node";
|
|
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,
|
|
resolveTransformAtTime,
|
|
} from "@/lib/animation";
|
|
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
|
|
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
|
|
import { masksRegistry } from "@/lib/masks";
|
|
import { getSourceTimeAtClipTime } from "@/lib/retime";
|
|
import { gpuRenderer } from "../gpu-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 {
|
|
const clipTime = time - this.params.timeOffset;
|
|
return (
|
|
this.params.trimStart +
|
|
getSourceTimeAtClipTime({
|
|
clipTime,
|
|
retime: this.params.retime,
|
|
})
|
|
);
|
|
}
|
|
|
|
protected getAnimationLocalTime({ time }: { time: number }): number {
|
|
return getElementLocalTime({
|
|
timelineTime: time,
|
|
elementStartTime: this.params.timeOffset,
|
|
elementDuration: this.params.duration,
|
|
});
|
|
}
|
|
|
|
protected isInRange({ time }: { time: number }): boolean {
|
|
const localTime = time - this.params.timeOffset;
|
|
return (
|
|
localTime >= 0 &&
|
|
localTime < this.params.duration
|
|
);
|
|
}
|
|
|
|
protected renderVisual({
|
|
renderer,
|
|
source,
|
|
sourceWidth,
|
|
sourceHeight,
|
|
timelineTime,
|
|
}: {
|
|
renderer: CanvasRenderer;
|
|
source: CanvasImageSource;
|
|
sourceWidth: number;
|
|
sourceHeight: number;
|
|
timelineTime: number;
|
|
}): void {
|
|
renderer.context.save();
|
|
|
|
const animationLocalTime = this.getAnimationLocalTime({
|
|
time: timelineTime,
|
|
});
|
|
const transform = resolveTransformAtTime({
|
|
baseTransform: this.params.transform,
|
|
animations: this.params.animations,
|
|
localTime: animationLocalTime,
|
|
});
|
|
const opacity = resolveOpacityAtTime({
|
|
baseOpacity: this.params.opacity,
|
|
animations: this.params.animations,
|
|
localTime: animationLocalTime,
|
|
});
|
|
const containScale = Math.min(
|
|
renderer.width / sourceWidth,
|
|
renderer.height / sourceHeight,
|
|
);
|
|
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"
|
|
? this.params.blendMode
|
|
: "source-over"
|
|
) as GlobalCompositeOperation;
|
|
renderer.context.globalAlpha = opacity;
|
|
|
|
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 (activeMasks.length === 0) {
|
|
renderer.context.drawImage(currentResult, x, y, absWidth, absHeight);
|
|
renderer.context.restore();
|
|
return;
|
|
}
|
|
|
|
const elementCanvas = createOffscreenCanvas({
|
|
width: Math.round(absWidth),
|
|
height: Math.round(absHeight),
|
|
});
|
|
const elementCtx = elementCanvas.getContext("2d") as
|
|
| CanvasRenderingContext2D
|
|
| OffscreenCanvasRenderingContext2D
|
|
| null;
|
|
if (!elementCtx) {
|
|
renderer.context.drawImage(currentResult, x, y, absWidth, absHeight);
|
|
renderer.context.restore();
|
|
return;
|
|
}
|
|
|
|
elementCtx.drawImage(currentResult, 0, 0, absWidth, absHeight);
|
|
|
|
for (const mask of activeMasks) {
|
|
this.applyMask({
|
|
mask,
|
|
elementCtx,
|
|
scaledWidth: absWidth,
|
|
scaledHeight: absHeight,
|
|
});
|
|
}
|
|
|
|
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 = effectsRegistry.get(effect.type);
|
|
const passes = resolveEffectPasses({
|
|
definition,
|
|
effectParams: resolvedParams,
|
|
width,
|
|
height,
|
|
});
|
|
current = gpuRenderer.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),
|
|
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);
|
|
}
|
|
}
|
|
}
|