refactor: split animation helpers by domain

This commit is contained in:
Maze Winther
2026-04-26 20:44:07 +02:00
67 changed files with 2754 additions and 1547 deletions
@@ -3,6 +3,11 @@ import type { AnyBaseNode } from "./nodes/base-node";
import { buildFrameDescriptor } from "./compositor/frame-descriptor";
import { wasmCompositor } from "./compositor/wasm-compositor";
import { resolveRenderTree } from "./resolve";
import {
measureSpanAsync,
measureSpanSync,
onRenderPerfFrameComplete,
} from "@/diagnostics/render-perf";
export type CanvasRendererParams = {
width: number;
@@ -69,17 +74,26 @@ export class CanvasRenderer {
}
async render({ node, time }: { node: AnyBaseNode; time: number }) {
await resolveRenderTree({ node, renderer: this, time });
const { frame, textures } = await buildFrameDescriptor({
node,
renderer: this,
await measureSpanAsync({
name: "resolve",
fn: () => resolveRenderTree({ node, renderer: this, time }),
});
const { frame, textures } = await measureSpanAsync({
name: "buildFrame",
fn: () => buildFrameDescriptor({ node, renderer: this }),
});
wasmCompositor.ensureInitialized({
width: this.width,
height: this.height,
});
wasmCompositor.syncTextures(textures);
wasmCompositor.render(frame);
measureSpanSync({
name: "syncTextures",
fn: () => wasmCompositor.syncTextures(textures),
});
measureSpanSync({
name: "renderFrame",
fn: () => wasmCompositor.render(frame),
});
}
async renderToCanvas({
@@ -98,12 +112,17 @@ export class CanvasRenderer {
throw new Error("Failed to get target canvas context");
}
ctx.drawImage(
wasmCompositor.getCanvas(),
0,
0,
targetCanvas.width,
targetCanvas.height,
);
measureSpanSync({
name: "drawImage",
fn: () =>
ctx.drawImage(
wasmCompositor.getCanvas(),
0,
0,
targetCanvas.width,
targetCanvas.height,
),
});
onRenderPerfFrameComplete();
}
}
@@ -1,5 +1,6 @@
import { drawCssBackground } from "@/gradients";
import { masksRegistry } from "@/masks";
import { incrementCounter } from "@/diagnostics/render-perf";
import type { AnyBaseNode } from "../nodes/base-node";
import type { CanvasRenderer } from "../canvas-renderer";
import { createOffscreenCanvas } from "../canvas-utils";
@@ -21,16 +22,11 @@ import type {
FrameItemDescriptor,
LayerMaskDescriptor,
QuadTransformDescriptor,
TextureCanvasDrawFn,
TextureUploadDescriptor,
} from "./types";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
export type TextureUploadDescriptor = {
id: string;
source: CanvasImageSource;
width: number;
height: number;
};
export async function buildFrameDescriptor({
node,
renderer,
@@ -52,6 +48,9 @@ export async function buildFrameDescriptor({
textures,
});
incrementCounter({ name: "frameItems", by: items.length });
incrementCounter({ name: "frameTextures", by: textures.size });
return {
frame: {
width: renderer.width,
@@ -93,31 +92,21 @@ async function collectNode({
if (node instanceof ColorNode) {
const textureId = `${path}:color`;
const canvas = createOffscreenCanvas({
width: renderer.width,
height: renderer.height,
});
const ctx = canvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!ctx) return;
if (/gradient\(/i.test(node.params.color)) {
drawCssBackground({
ctx,
width: renderer.width,
height: renderer.height,
css: node.params.color,
});
} else {
ctx.fillStyle = node.params.color;
ctx.fillRect(0, 0, renderer.width, renderer.height);
}
const { width, height } = renderer;
textures.set(textureId, {
kind: "rendered",
id: textureId,
source: canvas,
width: renderer.width,
height: renderer.height,
contentHash: `color:${node.params.color}:${width}x${height}`,
width,
height,
draw: (ctx) => {
if (/gradient\(/i.test(node.params.color)) {
drawCssBackground({ ctx, width, height, css: node.params.color });
} else {
ctx.fillStyle = node.params.color;
ctx.fillRect(0, 0, width, height);
}
},
});
items.push({
type: "layer",
@@ -147,36 +136,35 @@ async function collectNode({
return;
}
const textureId = `${path}:blur-background`;
const backdropCanvas = createOffscreenCanvas({
width: renderer.width,
height: renderer.height,
});
const backdropCtx = backdropCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!backdropCtx) return;
const { width, height } = renderer;
const { backdropSource, passes } = node.resolved;
const coverScale = Math.max(
renderer.width / backdropSource.width,
renderer.height / backdropSource.height,
);
const scaledWidth = backdropSource.width * coverScale;
const scaledHeight = backdropSource.height * coverScale;
const offsetX = (renderer.width - scaledWidth) / 2;
const offsetY = (renderer.height - scaledHeight) / 2;
backdropCtx.drawImage(
backdropSource.source,
offsetX,
offsetY,
scaledWidth,
scaledHeight,
);
// Backdrop pixels come from a decoded video/image frame whose identity
// already changes when it changes. Hashing the source reference is
// enough to let us skip redraws on frozen frames.
const contentHash = `blur:${identityKey(backdropSource.source)}:${backdropSource.width}x${backdropSource.height}:${width}x${height}`;
textures.set(textureId, {
kind: "rendered",
id: textureId,
source: backdropCanvas,
width: renderer.width,
height: renderer.height,
contentHash,
width,
height,
draw: (ctx) => {
const coverScale = Math.max(
width / backdropSource.width,
height / backdropSource.height,
);
const scaledWidth = backdropSource.width * coverScale;
const scaledHeight = backdropSource.height * coverScale;
const offsetX = (width - scaledWidth) / 2;
const offsetY = (height - scaledHeight) / 2;
ctx.drawImage(
backdropSource.source,
offsetX,
offsetY,
scaledWidth,
scaledHeight,
);
},
});
items.push({
type: "layer",
@@ -253,6 +241,7 @@ async function collectVisualSourceNode({
const textureId = `${path}:source`;
textures.set(textureId, {
kind: "external",
id: textureId,
source,
width: sourceWidth,
@@ -305,28 +294,24 @@ function collectTextNode({
}
const textureId = `${path}:text`;
const canvas = createOffscreenCanvas({
width: renderer.width,
height: renderer.height,
});
const ctx = canvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!ctx) {
return;
}
renderTextToContext({
node,
ctx,
});
const { width, height } = renderer;
// Text output is fully determined by node.params + node.resolved. Both are
// plain data we can stringify cheaply; the resolved measured layout is the
// expensive part of text setup, so stringifying it here is orders of
// magnitude cheaper than re-rasterizing when nothing changed.
const contentHash = `text:${width}x${height}:${JSON.stringify({
params: node.params,
resolved: node.resolved,
})}`;
textures.set(textureId, {
kind: "rendered",
id: textureId,
source: canvas,
width: renderer.width,
height: renderer.height,
contentHash,
width,
height,
draw: (ctx) => {
renderTextToContext({ node, ctx });
},
});
items.push({
type: "layer",
@@ -411,20 +396,6 @@ function buildMaskArtifacts({
return { mask: null, strokeLayer: null };
}
const elementMaskCanvas = createOffscreenCanvas({
width: Math.round(transform.width),
height: Math.round(transform.height),
});
const elementMaskCtx = elementMaskCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!elementMaskCtx) {
return { mask: null, strokeLayer: null };
}
elementMaskCtx.clearRect(0, 0, transform.width, transform.height);
let strokePath: Path2D | null = null;
let feather = mask.params.feather;
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
const shouldRenderMaskDirectly =
@@ -432,81 +403,78 @@ function buildMaskArtifacts({
(!definition.renderer.buildPath ||
(mask.params.feather > 0 &&
definition.renderer.renderMaskHandlesFeather));
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
definition.renderer.renderMask({
resolvedParams: mask.params,
ctx: elementMaskCtx,
width: Math.round(transform.width),
height: Math.round(transform.height),
feather: mask.params.feather,
});
if (definition.renderer.renderMaskHandlesFeather) {
feather = 0;
}
strokePath =
definition.renderer.buildStrokePath?.({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ?? null;
} else {
if (!definition.renderer.buildPath) {
return { mask: null, strokeLayer: null };
}
const path2d = definition.renderer.buildPath({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
});
elementMaskCtx.fillStyle = "white";
elementMaskCtx.fill(path2d);
strokePath =
definition.renderer.buildStrokePath?.({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ?? path2d;
if (
shouldRenderMaskDirectly &&
definition.renderer.renderMaskHandlesFeather
) {
feather = 0;
}
const fullMaskCanvas = createOffscreenCanvas({
width: renderer.width,
height: renderer.height,
});
const fullMaskCtx = fullMaskCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!fullMaskCtx) {
return { mask: null, strokeLayer: null };
}
drawTransformedCanvas({
ctx: fullMaskCtx,
source: elementMaskCanvas,
transform,
});
const maskTextureId = `${path}:mask`;
textures.set(maskTextureId, {
id: maskTextureId,
source: fullMaskCanvas,
width: renderer.width,
height: renderer.height,
});
let strokeLayer: FrameItemDescriptor | null = null;
if (
mask.params.strokeWidth > 0 &&
(strokePath || definition.renderer.renderStroke)
) {
const strokeCanvas = createOffscreenCanvas({
const { width: canvasWidth, height: canvasHeight } = renderer;
const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`;
const drawMask: TextureCanvasDrawFn = (ctx) => {
const elementMaskCanvas = createOffscreenCanvas({
width: Math.round(transform.width),
height: Math.round(transform.height),
});
const strokeCtx = strokeCanvas.getContext("2d") as
const elementMaskCtx = elementMaskCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (strokeCtx) {
if (!elementMaskCtx) return;
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
definition.renderer.renderMask({
resolvedParams: mask.params,
ctx: elementMaskCtx,
width: Math.round(transform.width),
height: Math.round(transform.height),
feather: mask.params.feather,
});
} else if (definition.renderer.buildPath) {
const path2d = definition.renderer.buildPath({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
});
elementMaskCtx.fillStyle = "white";
elementMaskCtx.fill(path2d);
} else {
return;
}
drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform });
};
textures.set(maskTextureId, {
kind: "rendered",
id: maskTextureId,
contentHash: maskContentHash,
width: canvasWidth,
height: canvasHeight,
draw: drawMask,
});
const hasStroke =
mask.params.strokeWidth > 0 &&
(definition.renderer.renderStroke ||
definition.renderer.buildStrokePath ||
definition.renderer.buildPath);
let strokeLayer: FrameItemDescriptor | null = null;
if (hasStroke) {
const strokeTextureId = `${path}:mask-stroke`;
const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`;
const drawStroke: TextureCanvasDrawFn = (ctx) => {
const strokeCanvas = createOffscreenCanvas({
width: Math.round(transform.width),
height: Math.round(transform.height),
});
const strokeCtx = strokeCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!strokeCtx) return;
if (definition.renderer.renderStroke) {
definition.renderer.renderStroke({
resolvedParams: mask.params,
@@ -514,44 +482,44 @@ function buildMaskArtifacts({
width: transform.width,
height: transform.height,
});
} else if (strokePath) {
} else {
const strokePath =
definition.renderer.buildStrokePath?.({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ??
definition.renderer.buildPath?.({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ??
null;
if (!strokePath) return;
strokeCtx.strokeStyle = mask.params.strokeColor;
strokeCtx.lineWidth = mask.params.strokeWidth;
strokeCtx.stroke(strokePath);
}
const fullStrokeCanvas = createOffscreenCanvas({
width: renderer.width,
height: renderer.height,
});
const fullStrokeCtx = fullStrokeCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (fullStrokeCtx) {
drawTransformedCanvas({
ctx: fullStrokeCtx,
source: strokeCanvas,
transform,
});
const strokeTextureId = `${path}:mask-stroke`;
textures.set(strokeTextureId, {
id: strokeTextureId,
source: fullStrokeCanvas,
width: renderer.width,
height: renderer.height,
});
strokeLayer = {
type: "layer",
textureId: strokeTextureId,
transform: fullCanvasTransform(renderer),
opacity: 1,
blendMode: "normal",
effectPassGroups: [],
mask: null,
};
}
}
drawTransformedCanvas({ ctx, source: strokeCanvas, transform });
};
textures.set(strokeTextureId, {
kind: "rendered",
id: strokeTextureId,
contentHash: strokeContentHash,
width: canvasWidth,
height: canvasHeight,
draw: drawStroke,
});
strokeLayer = {
type: "layer",
textureId: strokeTextureId,
transform: fullCanvasTransform(renderer),
opacity: 1,
blendMode: "normal",
effectPassGroups: [],
mask: null,
};
}
return {
@@ -590,3 +558,23 @@ function drawTransformedCanvas({
ctx.drawImage(source, x, y, transform.width, transform.height);
ctx.restore();
}
function transformHash(transform: QuadTransformDescriptor): string {
return `${transform.centerX}:${transform.centerY}:${transform.width}:${transform.height}:${transform.rotationDegrees}:${transform.flipX ? 1 : 0}:${transform.flipY ? 1 : 0}`;
}
// Stable identity key for CanvasImageSource. Using a WeakMap → counter keeps
// hash string length bounded and avoids holding sources alive.
const identityKeys = new WeakMap<object, number>();
let nextIdentity = 1;
function identityKey(source: CanvasImageSource): string {
if (typeof source === "object" && source !== null) {
let key = identityKeys.get(source);
if (key === undefined) {
key = nextIdentity++;
identityKeys.set(source, key);
}
return `@${key}`;
}
return "@?";
}
@@ -40,3 +40,39 @@ export type LayerMaskDescriptor = {
feather: number;
inverted: boolean;
};
export type TextureCanvasDrawFn = (
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
) => void;
/**
* A layer texture whose pixels come from somewhere outside the renderer —
* typically a decoded video/image frame or a sticker. Cached by reference
* identity of the source object.
*/
export type ExternalTextureDescriptor = {
kind: "external";
id: string;
source: CanvasImageSource;
width: number;
height: number;
};
/**
* A layer texture that the renderer rasterizes from scene state (color fill,
* text layout, mask shape, blur backdrop). Cached by `contentHash`: when it
* matches the previous frame's hash for this id, the upload is skipped
* entirely and the persistent canvas is not even cleared.
*/
export type RenderedTextureDescriptor = {
kind: "rendered";
id: string;
contentHash: string;
width: number;
height: number;
draw: TextureCanvasDrawFn;
};
export type TextureUploadDescriptor =
| ExternalTextureDescriptor
| RenderedTextureDescriptor;
@@ -1,12 +1,201 @@
import {
getCompositorCanvas,
getLastFrameProfile,
initCompositor,
releaseTexture,
renderFrame,
resizeCompositor,
uploadTexture,
} from "opencut-wasm";
import type { FrameDescriptor } from "./types";
import {
incrementCounter,
isRenderPerfEnabled,
recordWasmFrameProfile,
} from "@/diagnostics/render-perf";
import type {
ExternalTextureDescriptor,
FrameDescriptor,
RenderedTextureDescriptor,
TextureUploadDescriptor,
} from "./types";
/**
* One slot in the derived-texture cache. The OffscreenCanvas is persistent —
* we reuse and redraw into it across frames, which is the change that takes
* the WebGL upload path off the per-frame critical path for static content.
*/
type RenderedCacheEntry = {
kind: "rendered";
canvas: OffscreenCanvas;
contentHash: string;
width: number;
height: number;
};
type ExternalCacheEntry = {
kind: "external";
source: CanvasImageSource;
width: number;
height: number;
};
class WasmCompositor {
private canvas: HTMLCanvasElement | null = null;
private initializedSize: { width: number; height: number } | null = null;
private cache = new Map<string, RenderedCacheEntry | ExternalCacheEntry>();
ensureInitialized({ width, height }: { width: number; height: number }) {
if (!this.canvas) {
initCompositor(width, height);
this.canvas = getCompositorCanvas();
this.initializedSize = { width, height };
return;
}
if (
!this.initializedSize ||
this.initializedSize.width !== width ||
this.initializedSize.height !== height
) {
resizeCompositor(width, height);
this.initializedSize = { width, height };
}
}
getCanvas(): HTMLCanvasElement {
if (!this.canvas) {
throw new Error("Compositor is not initialized");
}
return this.canvas;
}
syncTextures(textures: TextureUploadDescriptor[]) {
const nextIds = new Set(textures.map((texture) => texture.id));
for (const previousId of this.cache.keys()) {
if (!nextIds.has(previousId)) {
releaseTexture(previousId);
this.cache.delete(previousId);
}
}
for (const texture of textures) {
if (texture.kind === "external") {
this.syncExternalTexture(texture);
} else {
this.syncRenderedTexture(texture);
}
}
}
render(frame: FrameDescriptor) {
renderFrame(frame);
if (isRenderPerfEnabled()) {
recordWasmFrameProfile(
getLastFrameProfile() as Array<{ name: string; durationMs: number }>,
);
}
}
private syncExternalTexture(texture: ExternalTextureDescriptor) {
const previous = this.cache.get(texture.id);
if (
previous?.kind === "external" &&
previous.source === texture.source &&
previous.width === texture.width &&
previous.height === texture.height
) {
incrementCounter({ name: "textureCacheHit" });
return;
}
incrementCounter({ name: "textureUpload" });
incrementCounter({
name: "textureUploadPixels",
by: texture.width * texture.height,
});
uploadTexture({
id: texture.id,
source: ensureOffscreenCanvas({
source: texture.source,
width: texture.width,
height: texture.height,
label: `texture upload ${texture.id}`,
}),
width: texture.width,
height: texture.height,
});
this.cache.set(texture.id, {
kind: "external",
source: texture.source,
width: texture.width,
height: texture.height,
});
}
private syncRenderedTexture(texture: RenderedTextureDescriptor) {
const previous = this.cache.get(texture.id);
if (
previous?.kind === "rendered" &&
previous.contentHash === texture.contentHash &&
previous.width === texture.width &&
previous.height === texture.height
) {
incrementCounter({ name: "textureCacheHit" });
return;
}
const canvas =
previous?.kind === "rendered" &&
previous.width === texture.width &&
previous.height === texture.height
? previous.canvas
: createBackingCanvas({
width: texture.width,
height: texture.height,
});
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error(`Failed to get 2d context for texture ${texture.id}`);
}
ctx.clearRect(0, 0, texture.width, texture.height);
texture.draw(ctx);
incrementCounter({ name: "textureUpload" });
incrementCounter({
name: "textureUploadPixels",
by: texture.width * texture.height,
});
uploadTexture({
id: texture.id,
source: canvas,
width: texture.width,
height: texture.height,
});
this.cache.set(texture.id, {
kind: "rendered",
canvas,
contentHash: texture.contentHash,
width: texture.width,
height: texture.height,
});
}
}
export const wasmCompositor = new WasmCompositor();
function createBackingCanvas({
width,
height,
}: {
width: number;
height: number;
}): OffscreenCanvas {
if (typeof OffscreenCanvas === "undefined") {
throw new Error("OffscreenCanvas is not supported in this environment");
}
return new OffscreenCanvas(width, height);
}
function ensureOffscreenCanvas({
source,
@@ -36,92 +225,3 @@ function ensureOffscreenCanvas({
context.drawImage(source, 0, 0, width, height);
return canvas;
}
export type TextureUploadDescriptor = {
id: string;
source: CanvasImageSource;
width: number;
height: number;
};
class WasmCompositor {
private canvas: HTMLCanvasElement | null = null;
private initializedSize: { width: number; height: number } | null = null;
private retainedTextureIds = new Set<string>();
private uploadedTextures = new Map<
string,
{ source: CanvasImageSource; width: number; height: number }
>();
ensureInitialized({ width, height }: { width: number; height: number }) {
if (!this.canvas) {
initCompositor(width, height);
this.canvas = getCompositorCanvas();
this.initializedSize = { width, height };
return;
}
if (
!this.initializedSize ||
this.initializedSize.width !== width ||
this.initializedSize.height !== height
) {
resizeCompositor(width, height);
this.initializedSize = { width, height };
}
}
getCanvas(): HTMLCanvasElement {
if (!this.canvas) {
throw new Error("Compositor is not initialized");
}
return this.canvas;
}
syncTextures(textures: TextureUploadDescriptor[]) {
const nextIds = new Set(textures.map((texture) => texture.id));
for (const previousId of this.retainedTextureIds) {
if (!nextIds.has(previousId)) {
releaseTexture(previousId);
this.uploadedTextures.delete(previousId);
}
}
for (const texture of textures) {
const previousTexture = this.uploadedTextures.get(texture.id);
if (
previousTexture?.source === texture.source &&
previousTexture.width === texture.width &&
previousTexture.height === texture.height
) {
continue;
}
const sourceCanvas = ensureOffscreenCanvas({
source: texture.source,
width: texture.width,
height: texture.height,
label: `texture upload ${texture.id}`,
});
uploadTexture({
id: texture.id,
source: sourceCanvas,
width: texture.width,
height: texture.height,
});
this.uploadedTextures.set(texture.id, {
source: texture.source,
width: texture.width,
height: texture.height,
});
}
this.retainedTextureIds = nextIds;
}
render(frame: FrameDescriptor) {
renderFrame(frame);
}
}
export const wasmCompositor = new WasmCompositor();
+10 -10
View File
@@ -1,11 +1,5 @@
import { mediaTimeToSeconds, roundMediaTime } from "@/wasm";
import {
getElementLocalTime,
resolveColorAtTime,
resolveGraphicParamsAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "@/animation";
import { getElementLocalTime } from "@/animation";
import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
import {
buildGaussianBlurPasses,
@@ -14,11 +8,16 @@ import {
import { effectsRegistry, resolveEffectPasses } from "@/effects";
import type { Effect, EffectPass } from "@/effects/types";
import { getSourceTimeAtClipTime } from "@/retime";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
import {
DEFAULT_GRAPHIC_SOURCE_SIZE,
resolveGraphicElementParamsAtTime,
} from "@/graphics";
import {
getTextMeasurementContext,
measureTextElement,
} from "@/text/measure-element";
import { resolveColorAtTime, resolveOpacityAtTime } from "@/animation/values";
import { resolveTransformAtTime } from "@/rendering/animation-values";
import { videoCache } from "@/services/video-cache/service";
import type { CanvasRenderer } from "./canvas-renderer";
import type { AnyBaseNode } from "./nodes/base-node";
@@ -113,7 +112,8 @@ function resolveEffectPassGroups({
.filter((effect) => effect.enabled)
.map((effect) => {
const resolvedParams = resolveEffectParamsAtTime({
effect,
effectId: effect.id,
params: effect.params,
animations,
localTime,
});
@@ -304,7 +304,7 @@ function resolveGraphicNode({
return {
...visualState,
resolvedParams: resolveGraphicParamsAtTime({
resolvedParams: resolveGraphicElementParamsAtTime({
element: node.params,
localTime: visualState.localTime,
}),