summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer/nodes
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services/renderer/nodes')
-rw-r--r--apps/web/src/services/renderer/nodes/blur-background-node.ts75
-rw-r--r--apps/web/src/services/renderer/nodes/composite-effect-node.ts77
-rw-r--r--apps/web/src/services/renderer/nodes/effect-layer-node.ts82
-rw-r--r--apps/web/src/services/renderer/nodes/image-node.ts3
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts3
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts275
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts5
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts104
8 files changed, 466 insertions, 158 deletions
diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts
deleted file mode 100644
index 504161d9..00000000
--- a/apps/web/src/services/renderer/nodes/blur-background-node.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import type { CanvasRenderer } from "../canvas-renderer";
-import { BaseNode } from "./base-node";
-
-export type BlurBackgroundNodeParams = {
- blurIntensity: number;
- contentNodes: BaseNode[];
-};
-
-export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
- private blurIntensity: number;
- private contentNodes: BaseNode[];
-
- constructor(params: BlurBackgroundNodeParams) {
- super(params);
- this.blurIntensity = params.blurIntensity;
- this.contentNodes = params.contentNodes;
- }
-
- async render({
- renderer,
- time,
- }: {
- renderer: CanvasRenderer;
- time: number;
- }): Promise<void> {
- let offscreen: OffscreenCanvas | HTMLCanvasElement;
- let offscreenCtx:
- | OffscreenCanvasRenderingContext2D
- | CanvasRenderingContext2D;
-
- try {
- offscreen = new OffscreenCanvas(renderer.width, renderer.height);
- const ctx = offscreen.getContext("2d");
- if (!ctx) {
- throw new Error("failed to get offscreen canvas context");
- }
- offscreenCtx = ctx;
- } catch {
- offscreen = document.createElement("canvas");
- offscreen.width = renderer.width;
- offscreen.height = renderer.height;
- const ctx = offscreen.getContext("2d");
- if (!ctx) {
- throw new Error("failed to get canvas context");
- }
- offscreenCtx = ctx;
- }
-
- const originalContext = renderer.context;
- renderer.context = offscreenCtx;
-
- for (const node of this.contentNodes) {
- await node.render({ renderer, time });
- }
-
- renderer.context = originalContext;
-
- const zoomScale = 1.4;
- const scaledWidth = renderer.width * zoomScale;
- const scaledHeight = renderer.height * zoomScale;
- const offsetX = (renderer.width - scaledWidth) / 2;
- const offsetY = (renderer.height - scaledHeight) / 2;
-
- renderer.context.save();
- renderer.context.filter = `blur(${this.blurIntensity}px)`;
- renderer.context.drawImage(
- offscreen as CanvasImageSource,
- offsetX,
- offsetY,
- scaledWidth,
- scaledHeight,
- );
- renderer.context.restore();
- }
-}
diff --git a/apps/web/src/services/renderer/nodes/composite-effect-node.ts b/apps/web/src/services/renderer/nodes/composite-effect-node.ts
new file mode 100644
index 00000000..3fbfffc1
--- /dev/null
+++ b/apps/web/src/services/renderer/nodes/composite-effect-node.ts
@@ -0,0 +1,77 @@
+import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
+import { getEffect } from "@/lib/effects";
+import type { EffectParamValues } from "@/types/effects";
+import { BaseNode } from "./base-node";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
+
+export type CompositeEffectNodeParams = {
+ contentNodes: BaseNode[];
+ effectType: string;
+ effectParams: EffectParamValues;
+ scale: number;
+};
+
+export class CompositeEffectNode extends BaseNode<CompositeEffectNodeParams> {
+ async render({
+ renderer,
+ time,
+ }: {
+ renderer: CanvasRenderer;
+ time: number;
+ }): Promise<void> {
+ const offscreen = createOffscreenCanvas({
+ width: renderer.width,
+ height: renderer.height,
+ });
+ const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
+ if (!offscreenCtx) {
+ throw new Error("failed to get offscreen canvas context");
+ }
+
+ const originalContext = renderer.context;
+ renderer.context = offscreenCtx;
+
+ for (const node of this.params.contentNodes) {
+ await node.render({ renderer, time });
+ }
+
+ renderer.context = originalContext;
+
+ const effectDefinition = getEffect({ effectType: this.params.effectType });
+ const scale = this.params.scale;
+ const scaledWidth = renderer.width * scale;
+ const scaledHeight = renderer.height * scale;
+ const offsetX = (renderer.width - scaledWidth) / 2;
+ const offsetY = (renderer.height - scaledHeight) / 2;
+
+ const passes = effectDefinition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: this.params.effectParams,
+ width: renderer.width,
+ height: renderer.height,
+ }),
+ }));
+ const effectResult = webglEffectRenderer.applyEffect({
+ source: offscreen as CanvasImageSource,
+ width: renderer.width,
+ height: renderer.height,
+ passes,
+ });
+
+ renderer.context.save();
+ renderer.context.drawImage(
+ effectResult,
+ 0,
+ 0,
+ renderer.width,
+ renderer.height,
+ offsetX,
+ offsetY,
+ scaledWidth,
+ scaledHeight,
+ );
+ renderer.context.restore();
+ }
+}
diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
new file mode 100644
index 00000000..3d84be2b
--- /dev/null
+++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
@@ -0,0 +1,82 @@
+import type { CanvasRenderer } from "../canvas-renderer";
+import { getEffect } from "@/lib/effects";
+import type { EffectParamValues } from "@/types/effects";
+import { BaseNode } from "./base-node";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
+
+const TIME_EPSILON = 1e-6;
+
+export type EffectLayerNodeParams = {
+ effectType: string;
+ effectParams: EffectParamValues;
+ timeOffset: number;
+ duration: number;
+};
+
+function isInRange({
+ time,
+ timeOffset,
+ duration,
+}: {
+ time: number;
+ timeOffset: number;
+ duration: number;
+}): boolean {
+ return (
+ time >= timeOffset - TIME_EPSILON &&
+ time < timeOffset + duration + TIME_EPSILON
+ );
+}
+
+// snapshots whatever is currently on the canvas, applies the effect, draws it back
+export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> {
+ async render({
+ renderer,
+ time,
+ }: {
+ renderer: CanvasRenderer;
+ time: number;
+ }): Promise<void> {
+ if (
+ !isInRange({
+ time,
+ timeOffset: this.params.timeOffset,
+ duration: this.params.duration,
+ })
+ ) {
+ return;
+ }
+
+ const source = renderer.context.canvas as CanvasImageSource;
+
+ const effectDefinition = getEffect({
+ effectType: this.params.effectType,
+ });
+
+ const passes = effectDefinition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: this.params.effectParams,
+ width: renderer.width,
+ height: renderer.height,
+ }),
+ }));
+ const effectResult = webglEffectRenderer.applyEffect({
+ source,
+ width: renderer.width,
+ height: renderer.height,
+ passes,
+ });
+
+ renderer.context.save();
+ renderer.context.clearRect(0, 0, renderer.width, renderer.height);
+ renderer.context.drawImage(
+ effectResult,
+ 0,
+ 0,
+ renderer.width,
+ renderer.height,
+ );
+ renderer.context.restore();
+ }
+}
diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts
index b3a0be74..6720a5e5 100644
--- a/apps/web/src/services/renderer/nodes/image-node.ts
+++ b/apps/web/src/services/renderer/nodes/image-node.ts
@@ -73,7 +73,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
- if (!this.isInRange(time)) {
+ if (!this.isInRange({ time })) {
return;
}
@@ -84,6 +84,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
source,
sourceWidth: width || renderer.width,
sourceHeight: height || renderer.height,
+ timelineTime: time,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index acf638eb..da271685 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -51,7 +51,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
- if (!this.isInRange(time)) {
+ if (!this.isInRange({ time })) {
return;
}
@@ -62,6 +62,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
source,
sourceWidth: width,
sourceHeight: height,
+ timelineTime: time,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index 76d78b31..86e24fdf 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -1,10 +1,14 @@
import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
import {
+ DEFAULT_TEXT_BACKGROUND,
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
} from "@/constants/text-constants";
import {
getMetricAscent,
@@ -12,6 +16,17 @@ import {
getTextBackgroundRect,
measureTextBlock,
} from "@/lib/text/layout";
+import {
+ getElementLocalTime,
+ resolveColorAtTime,
+ resolveNumberAtTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
+import { getEffect } from "@/lib/effects";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
+import { clamp } from "@/utils/math";
function scaleFontSize({
fontSize,
@@ -27,6 +42,9 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
return `"${fontFamily.replace(/"/g, '\\"')}"`;
}
+const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
+const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
+
function drawTextDecoration({
ctx,
textDecoration,
@@ -46,7 +64,7 @@ function drawTextDecoration({
}): void {
if (textDecoration === "none" || !textDecoration) return;
- const thickness = Math.max(1, scaledFontSize * 0.07);
+ const thickness = Math.max(1, scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO);
const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
@@ -60,7 +78,7 @@ function drawTextDecoration({
}
if (textDecoration === "line-through") {
- const strikeY = lineY - (ascent - descent) * 0.35;
+ const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO;
ctx.fillRect(xStart, strikeY, lineWidth, thickness);
}
}
@@ -84,19 +102,24 @@ export class TextNode extends BaseNode<TextNodeParams> {
return;
}
- renderer.context.save();
-
- const x = this.params.transform.position.x + this.params.canvasCenter.x;
- const y = this.params.transform.position.y + this.params.canvasCenter.y;
+ const localTime = getElementLocalTime({
+ timelineTime: time,
+ elementStartTime: this.params.startTime,
+ elementDuration: this.params.duration,
+ });
+ const transform = resolveTransformAtTime({
+ baseTransform: this.params.transform,
+ animations: this.params.animations,
+ localTime,
+ });
+ const opacity = resolveOpacityAtTime({
+ baseOpacity: this.params.opacity,
+ animations: this.params.animations,
+ localTime,
+ });
- renderer.context.translate(x, y);
- renderer.context.scale(
- this.params.transform.scale,
- this.params.transform.scale,
- );
- if (this.params.transform.rotate) {
- renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180);
- }
+ const x = transform.position.x + this.params.canvasCenter.x;
+ const y = transform.position.y + this.params.canvasCenter.y;
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal";
@@ -105,83 +128,193 @@ export class TextNode extends BaseNode<TextNodeParams> {
canvasHeight: this.params.canvasHeight,
});
const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
- renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
- renderer.context.textAlign = this.params.textAlign;
- renderer.context.fillStyle = this.params.color;
-
+ const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
const letterSpacing = this.params.letterSpacing ?? 0;
const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
- if ("letterSpacing" in renderer.context) {
- (
- renderer.context as CanvasRenderingContext2D & { letterSpacing: string }
- ).letterSpacing = `${letterSpacing}px`;
- }
-
const lines = this.params.content.split("\n");
const lineHeightPx = scaledFontSize * lineHeight;
const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const baseline = this.params.textBaseline ?? "middle";
+ const blendMode = (
+ this.params.blendMode && this.params.blendMode !== "normal"
+ ? this.params.blendMode
+ : "source-over"
+ ) as GlobalCompositeOperation;
+ renderer.context.save();
+ renderer.context.font = fontString;
renderer.context.textBaseline = baseline;
+ if ("letterSpacing" in renderer.context) {
+ (renderer.context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
+ }
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
+ renderer.context.restore();
+
const lineCount = lines.length;
+ const block = measureTextBlock({ lineMetrics, lineHeightPx, fallbackFontSize: scaledFontSize });
- const block = measureTextBlock({
- lineMetrics,
- lineHeightPx,
- fallbackFontSize: scaledFontSize,
+ const textColor = resolveColorAtTime({
+ baseColor: this.params.color,
+ animations: this.params.animations,
+ propertyPath: "color",
+ localTime,
});
+ const bg = this.params.background;
+ const resolvedBackground = {
+ ...bg,
+ color: resolveColorAtTime({
+ baseColor: bg.color,
+ animations: this.params.animations,
+ propertyPath: "background.color",
+ localTime,
+ }),
+ paddingX: resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ animations: this.params.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ }),
+ paddingY: resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ animations: this.params.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ }),
+ offsetX: resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ animations: this.params.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ }),
+ offsetY: resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ animations: this.params.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ }),
+ cornerRadius: resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: this.params.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ }),
+ };
- const prevAlpha = renderer.context.globalAlpha;
- renderer.context.globalCompositeOperation = (
- this.params.blendMode && this.params.blendMode !== "normal"
- ? this.params.blendMode
- : "source-over"
- ) as GlobalCompositeOperation;
- renderer.context.globalAlpha = this.params.opacity;
+ const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
+ ctx.font = fontString;
+ ctx.textAlign = this.params.textAlign;
+ ctx.textBaseline = baseline;
+ ctx.fillStyle = textColor;
+ if ("letterSpacing" in ctx) {
+ (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
+ }
- if (
- this.params.background.color &&
- this.params.background.color !== "transparent" &&
- lineCount > 0
- ) {
- const { color, cornerRadius = 0 } = this.params.background;
- const backgroundRect = getTextBackgroundRect({
- textAlign: this.params.textAlign,
- block,
- background: this.params.background,
- fontSizeRatio,
- });
- if (backgroundRect) {
- renderer.context.fillStyle = color;
- renderer.context.beginPath();
- renderer.context.roundRect(
- backgroundRect.left,
- backgroundRect.top,
- backgroundRect.width,
- backgroundRect.height,
- cornerRadius,
- );
- renderer.context.fill();
- renderer.context.fillStyle = this.params.color;
+ if (
+ this.params.background.enabled &&
+ this.params.background.color &&
+ this.params.background.color !== "transparent" &&
+ lineCount > 0
+ ) {
+ const backgroundRect = getTextBackgroundRect({
+ textAlign: this.params.textAlign,
+ block,
+ background: resolvedBackground,
+ fontSizeRatio,
+ });
+ if (backgroundRect) {
+ const p = clamp({ value: resolvedBackground.cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100;
+ const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p;
+ ctx.fillStyle = resolvedBackground.color;
+ ctx.beginPath();
+ ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius);
+ ctx.fill();
+ ctx.fillStyle = textColor;
+ }
}
+
+ for (let i = 0; i < lineCount; i++) {
+ const lineY = i * lineHeightPx - block.visualCenterOffset;
+ ctx.fillText(lines[i], 0, lineY);
+ drawTextDecoration({
+ ctx,
+ textDecoration: this.params.textDecoration ?? "none",
+ lineWidth: lineMetrics[i].width,
+ lineY,
+ metrics: lineMetrics[i],
+ scaledFontSize,
+ textAlign: this.params.textAlign,
+ });
+ }
+ };
+
+ const applyTransform = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
+ ctx.translate(x, y);
+ ctx.scale(transform.scale, transform.scale);
+ if (transform.rotate) {
+ ctx.rotate((transform.rotate * Math.PI) / 180);
+ }
+ };
+
+ const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? [];
+
+ if (enabledEffects.length === 0) {
+ renderer.context.save();
+ applyTransform(renderer.context);
+ renderer.context.globalCompositeOperation = blendMode;
+ renderer.context.globalAlpha = opacity;
+ drawContent(renderer.context);
+ renderer.context.restore();
+ return;
+ }
+
+ // Effects path: render text to a same-size offscreen canvas so the blur
+ // can spread into the surrounding transparent area without hard clipping.
+ const offscreen = createOffscreenCanvas({ width: renderer.width, height: renderer.height });
+ const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
+
+ if (!offscreenCtx) {
+ renderer.context.save();
+ applyTransform(renderer.context);
+ renderer.context.globalCompositeOperation = blendMode;
+ renderer.context.globalAlpha = opacity;
+ drawContent(renderer.context);
+ renderer.context.restore();
+ return;
}
- for (let i = 0; i < lineCount; i++) {
- const y = i * lineHeightPx - block.visualCenterOffset;
- renderer.context.fillText(lines[i], 0, y);
- drawTextDecoration({
- ctx: renderer.context,
- textDecoration: this.params.textDecoration ?? "none",
- lineWidth: lineMetrics[i].width,
- lineY: y,
- metrics: lineMetrics[i],
- scaledFontSize,
- textAlign: this.params.textAlign,
+ offscreenCtx.save();
+ applyTransform(offscreenCtx);
+ drawContent(offscreenCtx);
+ offscreenCtx.restore();
+
+ let currentSource: CanvasImageSource = offscreen;
+ for (const effect of enabledEffects) {
+ const resolvedParams = resolveEffectParamsAtTime({
+ effect,
+ animations: this.params.animations,
+ localTime,
+ });
+ const definition = getEffect({ effectType: effect.type });
+ const passes = definition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: resolvedParams,
+ width: renderer.width,
+ height: renderer.height,
+ }),
+ }));
+ currentSource = webglEffectRenderer.applyEffect({
+ source: currentSource,
+ width: renderer.width,
+ height: renderer.height,
+ passes,
});
}
- renderer.context.globalAlpha = prevAlpha;
+ renderer.context.save();
+ renderer.context.globalCompositeOperation = blendMode;
+ renderer.context.globalAlpha = opacity;
+ renderer.context.drawImage(currentSource, 0, 0);
renderer.context.restore();
}
}
diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts
index 8e2ddccd..503c9c03 100644
--- a/apps/web/src/services/renderer/nodes/video-node.ts
+++ b/apps/web/src/services/renderer/nodes/video-node.ts
@@ -12,11 +12,11 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
- if (!this.isInRange(time)) {
+ if (!this.isInRange({ time })) {
return;
}
- const videoTime = this.getLocalTime(time);
+ const videoTime = this.getSourceLocalTime({ time });
const frame = await videoCache.getFrameAt({
mediaId: this.params.mediaId,
file: this.params.file,
@@ -29,6 +29,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
source: frame.canvas,
sourceWidth: frame.canvas.width,
sourceHeight: frame.canvas.height,
+ timelineTime: time,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 7179369e..6c50aa12 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -1,9 +1,19 @@
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";
-
-const VISUAL_EPSILON = 1 / 1000;
+import type { ElementAnimations } from "@/types/animation";
+import {
+ getElementLocalTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+} 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";
export interface VisualNodeParams {
duration: number;
@@ -11,21 +21,31 @@ export interface VisualNodeParams {
trimStart: number;
trimEnd: number;
transform: Transform;
+ animations?: ElementAnimations;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export abstract class VisualNode<
Params extends VisualNodeParams = VisualNodeParams,
> extends BaseNode<Params> {
- protected getLocalTime(time: number): number {
+ protected getSourceLocalTime({ time }: { time: number }): number {
return time - this.params.timeOffset + this.params.trimStart;
}
- protected isInRange(time: number): boolean {
- const localTime = this.getLocalTime(time);
+ 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 = this.getSourceLocalTime({ time });
return (
- localTime >= this.params.trimStart - VISUAL_EPSILON &&
+ localTime >= this.params.trimStart - TIME_EPSILON_SECONDS &&
localTime < this.params.trimStart + this.params.duration
);
}
@@ -35,15 +55,27 @@ export abstract class VisualNode<
source,
sourceWidth,
sourceHeight,
+ timelineTime,
}: {
renderer: CanvasRenderer;
source: CanvasImageSource;
sourceWidth: number;
sourceHeight: number;
+ timelineTime: number;
}): void {
renderer.context.save();
- const { transform, opacity } = this.params;
+ 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,
@@ -68,7 +100,63 @@ export abstract class VisualNode<
renderer.context.translate(-centerX, -centerY);
}
- renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ const enabledEffects =
+ this.params.effects?.filter((effect) => effect.enabled) ?? [];
+
+ if (enabledEffects.length === 0) {
+ renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ renderer.context.restore();
+ return;
+ }
+
+ const elementCanvas = createOffscreenCanvas({
+ width: Math.round(scaledWidth),
+ height: Math.round(scaledHeight),
+ });
+ const elementCtx = elementCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!elementCtx) {
+ renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ renderer.context.restore();
+ return;
+ }
+
+ elementCtx.drawImage(source, 0, 0, scaledWidth, scaledHeight);
+
+ let currentResult: CanvasImageSource = elementCanvas;
+
+ for (const effect of enabledEffects) {
+ 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),
+ passes,
+ });
+ }
+
+ renderer.context.drawImage(
+ currentResult,
+ x,
+ y,
+ scaledWidth,
+ scaledHeight,
+ );
renderer.context.restore();
}
}