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 | |
| parent | 36efdf66e707c818b81009acf8a00630a0309ca1 (diff) | |
| download | opencut-cad88ee4d1c9e06e7529b88ef5e5d3fdf3f89149.tar.gz | |
refactor: rendering pipeline
Made-with: Cursor
Diffstat (limited to 'apps/web/src/services/renderer/nodes')
| -rw-r--r-- | apps/web/src/services/renderer/nodes/base-node.ts | 27 | ||||
| -rw-r--r-- | apps/web/src/services/renderer/nodes/blur-background-node.ts | 136 | ||||
| -rw-r--r-- | apps/web/src/services/renderer/nodes/color-node.ts | 26 | ||||
| -rw-r--r-- | apps/web/src/services/renderer/nodes/effect-layer-node.ts | 78 | ||||
| -rw-r--r-- | apps/web/src/services/renderer/nodes/graphic-node.ts | 166 | ||||
| -rw-r--r-- | apps/web/src/services/renderer/nodes/image-node.ts | 37 | ||||
| -rw-r--r-- | apps/web/src/services/renderer/nodes/sticker-node.ts | 49 | ||||
| -rw-r--r-- | apps/web/src/services/renderer/nodes/text-node.ts | 356 | ||||
| -rw-r--r-- | apps/web/src/services/renderer/nodes/video-node.ts | 41 | ||||
| -rw-r--r-- | apps/web/src/services/renderer/nodes/visual-node.ts | 294 |
10 files changed, 251 insertions, 959 deletions
diff --git a/apps/web/src/services/renderer/nodes/base-node.ts b/apps/web/src/services/renderer/nodes/base-node.ts index 55bd055a..1d6fc0e4 100644 --- a/apps/web/src/services/renderer/nodes/base-node.ts +++ b/apps/web/src/services/renderer/nodes/base-node.ts @@ -1,35 +1,26 @@ -import type { CanvasRenderer } from "../canvas-renderer"; - export type BaseNodeParams = object | undefined; +export type AnyBaseNode = BaseNode<BaseNodeParams, unknown>; -export class BaseNode<Params extends BaseNodeParams = BaseNodeParams> { +export class BaseNode< + Params extends BaseNodeParams = BaseNodeParams, + Resolved = unknown, +> { params: Params; + resolved: Resolved | null = null; constructor(params?: Params) { this.params = params ?? ({} as Params); } - children: BaseNode[] = []; + children: AnyBaseNode[] = []; - add(child: BaseNode) { + add(child: AnyBaseNode) { this.children.push(child); return this; } - remove(child: BaseNode) { + remove(child: AnyBaseNode) { this.children = this.children.filter((c) => c !== child); return this; } - - async render({ - renderer, - time, - }: { - renderer: CanvasRenderer; - time: number; - }): Promise<void> { - for (const child of this.children) { - await child.render({ renderer, time }); - } - } } diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts index 4cb45b33..d20e2533 100644 --- a/apps/web/src/services/renderer/nodes/blur-background-node.ts +++ b/apps/web/src/services/renderer/nodes/blur-background-node.ts @@ -1,13 +1,6 @@ -import { buildGaussianBlurPasses, intensityToSigma } from "@/lib/effects/definitions/blur"; -import { mediaTimeToSeconds } from "opencut-wasm"; -import { getSourceTimeAtClipTime } from "@/lib/retime"; -import { videoCache } from "@/services/video-cache/service"; +import type { EffectPass } from "@/lib/effects/types"; import type { RetimeConfig } from "@/lib/timeline"; -import type { CanvasRenderer } from "../canvas-renderer"; -import { createOffscreenCanvas } from "../canvas-utils"; -import { gpuRenderer } from "../gpu-renderer"; import { BaseNode } from "./base-node"; -import { loadImageSource, type CachedImageSource } from "./image-node"; export type BlurBackgroundNodeParams = { mediaId: string; @@ -22,127 +15,18 @@ export type BlurBackgroundNodeParams = { blurIntensity: number; }; -type BackdropSource = { +export type BackdropSource = { source: CanvasImageSource; width: number; height: number; }; -export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> { - private cachedImageSource: Promise<CachedImageSource> | null; - - constructor(params: BlurBackgroundNodeParams) { - super(params); - this.cachedImageSource = - params.mediaType === "image" ? loadImageSource(params.url) : null; - } - - private isInRange({ time }: { time: number }): boolean { - const localTime = time - this.params.timeOffset; - return localTime >= 0 && localTime < this.params.duration; - } - - private getSourceLocalTime({ time }: { time: number }): number { - const clipTime = time - this.params.timeOffset; - return ( - this.params.trimStart + - getSourceTimeAtClipTime({ - clipTime, - retime: this.params.retime, - }) - ); - } - - private async getBackdropSource({ - time, - }: { - time: number; - }): Promise<BackdropSource | null> { - if (this.params.mediaType === "video") { - const sourceTimeTicks = this.getSourceLocalTime({ time }); - const frame = await videoCache.getFrameAt({ - mediaId: this.params.mediaId, - file: this.params.file, - time: mediaTimeToSeconds({ time: sourceTimeTicks }), - }); - - if (!frame) { - return null; - } - - return { - source: frame.canvas, - width: frame.canvas.width, - height: frame.canvas.height, - }; - } - - if (!this.cachedImageSource) { - return null; - } - - const { source, width, height } = await this.cachedImageSource; - return { source, width, height }; - } - - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { - await super.render({ renderer, time }); - - if (!this.isInRange({ time })) { - return; - } - - const backdropSource = await this.getBackdropSource({ time }); - if (!backdropSource) { - return; - } - - const offscreen = createOffscreenCanvas({ - width: renderer.width, - height: renderer.height, - }); - const offscreenCtx = offscreen.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!offscreenCtx) { - return; - } - - 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; - - offscreenCtx.drawImage( - backdropSource.source, - offsetX, - offsetY, - scaledWidth, - scaledHeight, - ); - - const passes = buildGaussianBlurPasses({ - sigmaX: intensityToSigma({ intensity: this.params.blurIntensity, resolution: renderer.width, reference: 1920 }), - sigmaY: intensityToSigma({ intensity: this.params.blurIntensity, resolution: renderer.height, reference: 1080 }), - }); - const effectResult = gpuRenderer.applyEffect({ - source: offscreen as CanvasImageSource, - width: renderer.width, - height: renderer.height, - passes, - }); - - renderer.context.drawImage( - effectResult, - 0, - 0, - renderer.width, - renderer.height, - ); - } +export interface ResolvedBlurBackgroundNodeState { + backdropSource: BackdropSource; + passes: EffectPass[]; } + +export class BlurBackgroundNode extends BaseNode< + BlurBackgroundNodeParams, + ResolvedBlurBackgroundNodeState +> {} diff --git a/apps/web/src/services/renderer/nodes/color-node.ts b/apps/web/src/services/renderer/nodes/color-node.ts index d3afbf9b..493fef64 100644 --- a/apps/web/src/services/renderer/nodes/color-node.ts +++ b/apps/web/src/services/renderer/nodes/color-node.ts @@ -1,31 +1,7 @@ -import { drawCssBackground } from "@/lib/gradients"; -import type { CanvasRenderer } from "../canvas-renderer"; import { BaseNode } from "./base-node"; export type ColorNodeParams = { color: string; }; -export class ColorNode extends BaseNode<ColorNodeParams> { - private color: string; - - constructor(params: ColorNodeParams) { - super(params); - this.color = params.color; - } - - async render({ renderer }: { renderer: CanvasRenderer }) { - if (/gradient\(/i.test(this.color)) { - drawCssBackground({ - ctx: renderer.context, - width: renderer.width, - height: renderer.height, - css: this.color, - }); - return; - } - - renderer.context.fillStyle = this.color; - renderer.context.fillRect(0, 0, renderer.width, renderer.height); - } -} +export class ColorNode extends BaseNode<ColorNodeParams> {} diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts index d8389fb5..d6e70fcf 100644 --- a/apps/web/src/services/renderer/nodes/effect-layer-node.ts +++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts @@ -1,10 +1,6 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { effectsRegistry, resolveEffectPasses } from "@/lib/effects"; +import type { EffectPass } from "@/lib/effects/types"; import type { ParamValues } from "@/lib/params"; import { BaseNode } from "./base-node"; -import { gpuRenderer } from "../gpu-renderer"; - -const TIME_EPSILON = 1e-6; export type EffectLayerNodeParams = { effectType: string; @@ -13,69 +9,11 @@ export type EffectLayerNodeParams = { 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 = effectsRegistry.get(this.params.effectType); - - const passes = resolveEffectPasses({ - definition: effectDefinition, - effectParams: this.params.effectParams, - width: renderer.width, - height: renderer.height, - }); - if (passes.length === 0) { - return; - } - const effectResult = gpuRenderer.applyEffect({ - source, - width: renderer.width, - height: renderer.height, - passes, - }); +export type ResolvedEffectLayerNodeState = { + passes: EffectPass[]; +}; - 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(); - } -} +export class EffectLayerNode extends BaseNode< + EffectLayerNodeParams, + ResolvedEffectLayerNodeState +> {} diff --git a/apps/web/src/services/renderer/nodes/graphic-node.ts b/apps/web/src/services/renderer/nodes/graphic-node.ts index 0a3dc408..494d2179 100644 --- a/apps/web/src/services/renderer/nodes/graphic-node.ts +++ b/apps/web/src/services/renderer/nodes/graphic-node.ts @@ -1,92 +1,74 @@ -import type { CanvasRenderer } from "../canvas-renderer";
-import { createOffscreenCanvas } from "../canvas-utils";
-import {
- DEFAULT_GRAPHIC_SOURCE_SIZE,
- getGraphicDefinition,
- registerDefaultGraphics,
-} from "@/lib/graphics";
-import { resolveGraphicParamsAtTime } from "@/lib/animation";
-import type { ParamValues } from "@/lib/params";
-import { VisualNode, type VisualNodeParams } from "./visual-node";
-
-export interface GraphicNodeParams extends VisualNodeParams {
- definitionId: string;
- params: ParamValues;
-}
-
-export class GraphicNode extends VisualNode<GraphicNodeParams> {
- private cachedKey: string | null = null;
- private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null;
-
- constructor(params: GraphicNodeParams) {
- super(params);
- registerDefaultGraphics();
- }
-
- private getSource({
- localTime,
- }: {
- localTime: number;
- }): OffscreenCanvas | HTMLCanvasElement | null {
- const definition = getGraphicDefinition({
- definitionId: this.params.definitionId,
- });
- const resolvedParams = resolveGraphicParamsAtTime({
- element: this.params,
- localTime,
- });
- const cacheKey = JSON.stringify({
- definitionId: this.params.definitionId,
- params: resolvedParams,
- });
- if (this.cachedSource && this.cachedKey === cacheKey) {
- return this.cachedSource;
- }
-
- const canvas = createOffscreenCanvas({
- width: DEFAULT_GRAPHIC_SOURCE_SIZE,
- height: DEFAULT_GRAPHIC_SOURCE_SIZE,
- });
- const ctx = canvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (!ctx) {
- return null;
- }
-
- definition.render({
- ctx,
- params: resolvedParams,
- width: DEFAULT_GRAPHIC_SOURCE_SIZE,
- height: DEFAULT_GRAPHIC_SOURCE_SIZE,
- });
-
- this.cachedKey = cacheKey;
- this.cachedSource = canvas;
- return canvas;
- }
-
- async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
- await super.render({ renderer, time });
-
- if (!this.isInRange({ time })) {
- return;
- }
-
- const source = this.getSource({
- localTime: this.getAnimationLocalTime({ time }),
- });
- if (!source) {
- return;
- }
-
- this.renderVisual({
- renderer,
- source,
- sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
- sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
- timelineTime: time,
- });
- }
-}
+import { createOffscreenCanvas } from "../canvas-utils"; +import { + DEFAULT_GRAPHIC_SOURCE_SIZE, + getGraphicDefinition, + registerDefaultGraphics, +} from "@/lib/graphics"; +import type { ParamValues } from "@/lib/params"; +import { + VisualNode, + type ResolvedVisualNodeState, + type VisualNodeParams, +} from "./visual-node"; + +export interface GraphicNodeParams extends VisualNodeParams { + definitionId: string; + params: ParamValues; +} + +export interface ResolvedGraphicNodeState extends ResolvedVisualNodeState { + resolvedParams: ParamValues; +} + +export class GraphicNode extends VisualNode< + GraphicNodeParams, + ResolvedGraphicNodeState +> { + private cachedKey: string | null = null; + private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null; + + constructor(params: GraphicNodeParams) { + super(params); + registerDefaultGraphics(); + } + + getSource({ + resolvedParams, + }: { + resolvedParams: ParamValues; + }): OffscreenCanvas | HTMLCanvasElement | null { + const definition = getGraphicDefinition({ + definitionId: this.params.definitionId, + }); + const cacheKey = JSON.stringify({ + definitionId: this.params.definitionId, + params: resolvedParams, + }); + if (this.cachedSource && this.cachedKey === cacheKey) { + return this.cachedSource; + } + + const canvas = createOffscreenCanvas({ + width: DEFAULT_GRAPHIC_SOURCE_SIZE, + height: DEFAULT_GRAPHIC_SOURCE_SIZE, + }); + const ctx = canvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!ctx) { + return null; + } + + definition.render({ + ctx, + params: resolvedParams, + width: DEFAULT_GRAPHIC_SOURCE_SIZE, + height: DEFAULT_GRAPHIC_SOURCE_SIZE, + }); + + this.cachedKey = cacheKey; + this.cachedSource = canvas; + return canvas; + } +} diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index 7d74f511..d8c56803 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -1,5 +1,8 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { VisualNode, type VisualNodeParams } from "./visual-node"; +import { + VisualNode, + type ResolvedVisualSourceNodeState, + type VisualNodeParams, +} from "./visual-node"; export interface ImageNodeParams extends VisualNodeParams { url: string; @@ -62,29 +65,7 @@ export function loadImageSource( return promise; } -export class ImageNode extends VisualNode<ImageNodeParams> { - private cachedSource: Promise<CachedImageSource>; - - constructor(params: ImageNodeParams) { - super(params); - this.cachedSource = loadImageSource(params.url, params.maxSourceSize); - } - - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { - await super.render({ renderer, time }); - - if (!this.isInRange({ time })) { - return; - } - - const { source, width, height } = await this.cachedSource; - - this.renderVisual({ - renderer, - source, - sourceWidth: width || renderer.width, - sourceHeight: height || renderer.height, - timelineTime: time, - }); - } -} +export class ImageNode extends VisualNode< + ImageNodeParams, + ResolvedVisualSourceNodeState +> {} diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts index 71cb37b5..3c155cb6 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -1,6 +1,9 @@ -import type { CanvasRenderer } from "../canvas-renderer"; import { resolveStickerId } from "@/lib/stickers"; -import { VisualNode, type VisualNodeParams } from "./visual-node"; +import { + VisualNode, + type ResolvedVisualSourceNodeState, + type VisualNodeParams, +} from "./visual-node"; export interface StickerNodeParams extends VisualNodeParams { stickerId: string; @@ -16,7 +19,11 @@ interface CachedStickerSource { const stickerSourceCache = new Map<string, Promise<CachedStickerSource>>(); -function loadStickerSource({ stickerId }: { stickerId: string }): Promise<CachedStickerSource> { +export function loadStickerSource({ + stickerId, +}: { + stickerId: string; +}): Promise<CachedStickerSource> { const cached = stickerSourceCache.get(stickerId); if (cached) return cached; @@ -42,35 +49,7 @@ function loadStickerSource({ stickerId }: { stickerId: string }): Promise<Cached return promise; } -export class StickerNode extends VisualNode<StickerNodeParams> { - private cachedSource: Promise<CachedStickerSource>; - - constructor(params: StickerNodeParams) { - super(params); - this.cachedSource = loadStickerSource({ stickerId: params.stickerId }); - } - - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { - await super.render({ renderer, time }); - - if (!this.isInRange({ time })) { - return; - } - - const { source, width: loadedWidth, height: loadedHeight } = - await this.cachedSource; - - // Prefer element-stored intrinsic dimensions as the geometry authority. - // The loaded image is only the drawable source. - const sourceWidth = this.params.intrinsicWidth ?? loadedWidth; - const sourceHeight = this.params.intrinsicHeight ?? loadedHeight; - - this.renderVisual({ - renderer, - source, - sourceWidth, - sourceHeight, - timelineTime: time, - }); - } -} +export class StickerNode extends VisualNode< + StickerNodeParams, + ResolvedVisualSourceNodeState +> {} diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 643e538a..fc503991 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -1,289 +1,129 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { createOffscreenCanvas } from "../canvas-utils"; import { BaseNode } from "./base-node"; import type { TextElement } from "@/lib/timeline"; +import type { EffectPass } from "@/lib/effects/types"; +import type { Transform } from "@/lib/rendering"; import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN, } from "@/constants/text-constants"; import { - getMetricAscent, - getMetricDescent, + drawTextDecoration, getTextBackgroundRect, setCanvasLetterSpacing, } from "@/lib/text/layout"; -import { measureTextElement } from "@/lib/text/measure-element"; -import { - getElementLocalTime, - resolveColorAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, -} from "@/lib/animation"; -import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel"; -import { effectsRegistry, resolveEffectPasses } from "@/lib/effects"; -import { gpuRenderer } from "../gpu-renderer"; +import type { MeasuredTextElement } from "@/lib/text/measure-element"; import { clamp } from "@/utils/math"; -const TEXT_DECORATION_THICKNESS_RATIO = 0.07; -const STRIKETHROUGH_VERTICAL_RATIO = 0.35; - -function drawTextDecoration({ - ctx, - textDecoration, - lineWidth, - lineY, - metrics, - scaledFontSize, - textAlign, -}: { - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - textDecoration: string; - lineWidth: number; - lineY: number; - metrics: TextMetrics; - scaledFontSize: number; - textAlign: CanvasTextAlign; -}): void { - if (textDecoration === "none" || !textDecoration) return; - - const thickness = Math.max( - 1, - scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO, - ); - const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize }); - const descent = getMetricDescent({ - metrics, - fallbackFontSize: scaledFontSize, - }); - - let xStart = -lineWidth / 2; - if (textAlign === "left") xStart = 0; - if (textAlign === "right") xStart = -lineWidth; - - if (textDecoration === "underline") { - const underlineY = lineY + descent + thickness; - ctx.fillRect(xStart, underlineY, lineWidth, thickness); - } - - if (textDecoration === "line-through") { - const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO; - ctx.fillRect(xStart, strikeY, lineWidth, thickness); - } -} - export type TextNodeParams = TextElement & { canvasCenter: { x: number; y: number }; canvasHeight: number; textBaseline?: CanvasTextBaseline; }; -export class TextNode extends BaseNode<TextNodeParams> { - isInRange({ time }: { time: number }) { - return ( - time >= this.params.startTime && - time < this.params.startTime + this.params.duration - ); - } +export interface ResolvedTextNodeState { + transform: Transform; + opacity: number; + textColor: string; + backgroundColor: string; + effectPasses: EffectPass[][]; + measuredText: MeasuredTextElement; +} - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { - if (!this.isInRange({ time })) { - return; - } +export class TextNode extends BaseNode<TextNodeParams, ResolvedTextNodeState> {} - 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, - }); +export function renderTextToContext({ + node, + ctx, +}: { + node: TextNode; + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +}): void { + const resolved = node.resolved; + if (!resolved) { + return; + } + + const x = resolved.transform.position.x + node.params.canvasCenter.x; + const y = resolved.transform.position.y + node.params.canvasCenter.y; + const baseline = node.params.textBaseline ?? "middle"; + const { + scaledFontSize, + fontString, + letterSpacing, + lineHeightPx, + lines, + lineMetrics, + block, + fontSizeRatio, + resolvedBackground, + } = resolved.measuredText; + const lineCount = lines.length; + const resolvedBackgroundWithColor = { + ...resolvedBackground, + color: resolved.backgroundColor, + }; - const x = transform.position.x + this.params.canvasCenter.x; - const y = transform.position.y + this.params.canvasCenter.y; + ctx.save(); + ctx.translate(x, y); + ctx.scale(resolved.transform.scaleX, resolved.transform.scaleY); + if (resolved.transform.rotate) { + ctx.rotate((resolved.transform.rotate * Math.PI) / 180); + } - const baseline = this.params.textBaseline ?? "middle"; - const blendMode = ( - this.params.blendMode && this.params.blendMode !== "normal" - ? this.params.blendMode - : "source-over" - ) as GlobalCompositeOperation; + ctx.font = fontString; + ctx.textAlign = node.params.textAlign; + ctx.textBaseline = baseline; + ctx.fillStyle = resolved.textColor; + setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing }); - const { - scaledFontSize, - fontString, - letterSpacing, - lineHeightPx, - lines, - lineMetrics, + if ( + node.params.background.enabled && + node.params.background.color && + node.params.background.color !== "transparent" && + lineCount > 0 + ) { + const backgroundRect = getTextBackgroundRect({ + textAlign: node.params.textAlign, block, + background: resolvedBackgroundWithColor, fontSizeRatio, - resolvedBackground, - } = measureTextElement({ - element: this.params, - canvasHeight: this.params.canvasHeight, - localTime, - ctx: renderer.context, - }); - - const lineCount = lines.length; - - const textColor = resolveColorAtTime({ - baseColor: this.params.color, - animations: this.params.animations, - propertyPath: "color", - localTime, }); - const resolvedBackgroundWithColor = { - ...resolvedBackground, - color: resolveColorAtTime({ - baseColor: this.params.background.color, - animations: this.params.animations, - propertyPath: "background.color", - localTime, - }), - }; - - const drawContent = ( - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, - ) => { - ctx.font = fontString; - ctx.textAlign = this.params.textAlign; - ctx.textBaseline = baseline; - ctx.fillStyle = textColor; - setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing }); - - 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: resolvedBackgroundWithColor, - fontSizeRatio, - }); - if (backgroundRect) { - const p = - clamp({ - value: resolvedBackgroundWithColor.cornerRadius, - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - }) / 100; - const radius = - (Math.min(backgroundRect.width, backgroundRect.height) / 2) * p; - ctx.fillStyle = resolvedBackgroundWithColor.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.scaleX, transform.scaleY); - 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; + if (backgroundRect) { + const p = + clamp({ + value: resolvedBackgroundWithColor.cornerRadius, + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + }) / 100; + const radius = + (Math.min(backgroundRect.width, backgroundRect.height) / 2) * p; + ctx.fillStyle = resolvedBackgroundWithColor.color; + ctx.beginPath(); + ctx.roundRect( + backgroundRect.left, + backgroundRect.top, + backgroundRect.width, + backgroundRect.height, + radius, + ); + ctx.fill(); + ctx.fillStyle = resolved.textColor; } + } - // 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, + for (let index = 0; index < lineCount; index++) { + const lineY = index * lineHeightPx - block.visualCenterOffset; + ctx.fillText(lines[index], 0, lineY); + drawTextDecoration({ + ctx, + textDecoration: node.params.textDecoration ?? "none", + lineWidth: lineMetrics[index].width, + lineY, + metrics: lineMetrics[index], + scaledFontSize, + textAlign: node.params.textAlign, }); - 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; - } - - 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 = effectsRegistry.get(effect.type); - const passes = resolveEffectPasses({ - definition, - effectParams: resolvedParams, - width: renderer.width, - height: renderer.height, - }); - currentSource = gpuRenderer.applyEffect({ - source: currentSource, - width: renderer.width, - height: renderer.height, - passes, - }); - } - - renderer.context.save(); - renderer.context.globalCompositeOperation = blendMode; - renderer.context.globalAlpha = opacity; - renderer.context.drawImage(currentSource, 0, 0); - renderer.context.restore(); } + + ctx.restore(); } diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts index 4f225567..a903e2b2 100644 --- a/apps/web/src/services/renderer/nodes/video-node.ts +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -1,7 +1,8 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { mediaTimeToSeconds } from "opencut-wasm"; -import { VisualNode, type VisualNodeParams } from "./visual-node"; -import { videoCache } from "@/services/video-cache/service"; +import { + VisualNode, + type ResolvedVisualSourceNodeState, + type VisualNodeParams, +} from "./visual-node"; export interface VideoNodeParams extends VisualNodeParams { url: string; @@ -9,31 +10,7 @@ export interface VideoNodeParams extends VisualNodeParams { mediaId: string; } -export class VideoNode extends VisualNode<VideoNodeParams> { - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { - await super.render({ renderer, time }); - - if (!this.isInRange({ time })) { - return; - } - - const videoTimeTicks = this.getSourceLocalTime({ time }); - const videoTimeSeconds = mediaTimeToSeconds({ time: videoTimeTicks }); - - const frame = await videoCache.getFrameAt({ - mediaId: this.params.mediaId, - file: this.params.file, - time: videoTimeSeconds, - }); - - if (frame) { - this.renderVisual({ - renderer, - source: frame.canvas, - sourceWidth: frame.canvas.width, - sourceHeight: frame.canvas.height, - timelineTime: time, - }); - } - } -} +export class VideoNode extends VisualNode< + VideoNodeParams, + ResolvedVisualSourceNodeState +> {} 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> {} |
