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/base-node.ts27
-rw-r--r--apps/web/src/services/renderer/nodes/blur-background-node.ts136
-rw-r--r--apps/web/src/services/renderer/nodes/color-node.ts26
-rw-r--r--apps/web/src/services/renderer/nodes/effect-layer-node.ts78
-rw-r--r--apps/web/src/services/renderer/nodes/graphic-node.ts166
-rw-r--r--apps/web/src/services/renderer/nodes/image-node.ts37
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts49
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts356
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts41
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts294
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> {}