diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-13 04:41:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-13 05:08:29 +0200 |
| commit | cad88ee4d1c9e06e7529b88ef5e5d3fdf3f89149 (patch) | |
| tree | 0bfc1fcb18b3da4969afa2836bb65e6a5e25d123 /apps/web/src/services/renderer/nodes/visual-node.ts | |
| parent | 36efdf66e707c818b81009acf8a00630a0309ca1 (diff) | |
| download | opencut-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.ts | 294 |
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> {} |
