summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer/nodes/visual-node.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-13 04:41:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-13 05:08:29 +0200
commitcad88ee4d1c9e06e7529b88ef5e5d3fdf3f89149 (patch)
tree0bfc1fcb18b3da4969afa2836bb65e6a5e25d123 /apps/web/src/services/renderer/nodes/visual-node.ts
parent36efdf66e707c818b81009acf8a00630a0309ca1 (diff)
downloadopencut-cad88ee4d1c9e06e7529b88ef5e5d3fdf3f89149.tar.gz
refactor: rendering pipeline
Made-with: Cursor
Diffstat (limited to 'apps/web/src/services/renderer/nodes/visual-node.ts')
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts294
1 files changed, 19 insertions, 275 deletions
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 4a5fcdc6..010f146e 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -1,22 +1,8 @@
-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 { Effect, EffectPass } 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";
+import type { RetimeConfig, VisualElement } from "@/lib/timeline";
export interface VisualNodeParams {
duration: number;
@@ -25,270 +11,28 @@ export interface VisualNodeParams {
trimEnd: number;
retime?: RetimeConfig;
transform: Transform;
- animations?: ElementAnimations;
+ animations?: VisualElement["animations"];
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;
+export interface ResolvedVisualNodeState {
+ localTime: number;
+ transform: Transform;
+ opacity: number;
+ effectPasses: EffectPass[][];
+}
- if (mask.params.strokeWidth > 0 && strokePath) {
- elementCtx.strokeStyle = mask.params.strokeColor;
- elementCtx.lineWidth = mask.params.strokeWidth;
- elementCtx.stroke(strokePath);
- }
- }
+export interface ResolvedVisualSourceNodeState
+ extends ResolvedVisualNodeState {
+ source: CanvasImageSource;
+ sourceWidth: number;
+ sourceHeight: number;
}
+
+export abstract class VisualNode<
+ Params extends VisualNodeParams = VisualNodeParams,
+ Resolved extends ResolvedVisualNodeState = ResolvedVisualNodeState,
+> extends BaseNode<Params, Resolved> {}