summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-01 01:32:09 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-01 01:32:09 +0100
commit5e7c27f542e94ffee5d2106ce318b6f43cc7e738 (patch)
treeb33c3370c01b9b2ee685f1039543bc02f8764f7b /apps/web/src/services/renderer
parent744419469da403be57d30b5b19f6b43246caa57d (diff)
parent354f4dec67ef7eb39d34fca941ca34025a68136b (diff)
downloadopencut-5e7c27f542e94ffee5d2106ce318b6f43cc7e738.tar.gz
Merge branch 'dev' into ripple-editing
Diffstat (limited to 'apps/web/src/services/renderer')
-rw-r--r--apps/web/src/services/renderer/canvas-utils.ts16
-rw-r--r--apps/web/src/services/renderer/effect-preview.ts194
-rw-r--r--apps/web/src/services/renderer/nodes/blur-background-node.ts75
-rw-r--r--apps/web/src/services/renderer/nodes/composite-effect-node.ts77
-rw-r--r--apps/web/src/services/renderer/nodes/effect-layer-node.ts82
-rw-r--r--apps/web/src/services/renderer/nodes/image-node.ts83
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts56
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts147
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts5
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts98
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts165
-rw-r--r--apps/web/src/services/renderer/scene-exporter.ts4
-rw-r--r--apps/web/src/services/renderer/webgl-effect-renderer.ts73
-rw-r--r--apps/web/src/services/renderer/webgl-utils.ts298
14 files changed, 1106 insertions, 267 deletions
diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts
new file mode 100644
index 00000000..093168e6
--- /dev/null
+++ b/apps/web/src/services/renderer/canvas-utils.ts
@@ -0,0 +1,16 @@
+export function createOffscreenCanvas({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): OffscreenCanvas | HTMLCanvasElement {
+ try {
+ return new OffscreenCanvas(width, height);
+ } catch {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+ }
+}
diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts
new file mode 100644
index 00000000..2cebec5d
--- /dev/null
+++ b/apps/web/src/services/renderer/effect-preview.ts
@@ -0,0 +1,194 @@
+import { createOffscreenCanvas } from "./canvas-utils";
+import { getEffect } from "@/lib/effects";
+import type { EffectParamValues } from "@/types/effects";
+import { applyMultiPassEffect } from "./webgl-utils";
+import type { EffectPassData } from "./webgl-utils";
+
+const PREVIEW_SIZE = 160;
+const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
+
+let previewGl: WebGLRenderingContext | null = null;
+let previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+let testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+let previewImageElement: HTMLImageElement | null = null;
+const programCache = new Map<string, WebGLProgram>();
+const onReadyCallbacks = new Set<() => void>();
+
+export function onPreviewImageReady({
+ callback,
+}: {
+ callback: () => void;
+}): () => void {
+ onReadyCallbacks.add(callback);
+ return () => onReadyCallbacks.delete(callback);
+}
+
+function loadPreviewImage(): void {
+ if (typeof window === "undefined") return;
+ const image = new Image();
+ image.onload = () => {
+ testSourceCanvas = null;
+ for (const callback of onReadyCallbacks) {
+ callback();
+ }
+ };
+ image.src = PREVIEW_IMAGE_PATH;
+ previewImageElement = image;
+}
+
+loadPreviewImage();
+
+function buildDefaultParams({
+ effectType,
+}: {
+ effectType: string;
+}): EffectParamValues {
+ const definition = getEffect({ effectType });
+ const params: EffectParamValues = {};
+ for (const paramDef of definition.params) {
+ params[paramDef.key] = paramDef.default;
+ }
+ return params;
+}
+
+function createTestSource({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): OffscreenCanvas | HTMLCanvasElement | null {
+ const isImageReady =
+ previewImageElement?.complete &&
+ (previewImageElement.naturalWidth ?? 0) > 0;
+ if (!isImageReady || !previewImageElement) {
+ return null;
+ }
+
+ const canvas = createOffscreenCanvas({ width, height });
+ const ctx = canvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!ctx) {
+ throw new Error("failed to get 2d context for test source");
+ }
+ ctx.drawImage(previewImageElement, 0, 0, width, height);
+ return canvas;
+}
+
+function getOrCreatePreviewContext({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } {
+ if (!previewCanvas || !previewGl) {
+ previewCanvas = createOffscreenCanvas({ width, height });
+ previewGl = previewCanvas.getContext("webgl", {
+ premultipliedAlpha: false,
+ }) as WebGLRenderingContext | null;
+ if (!previewGl) {
+ throw new Error("WebGL not supported");
+ }
+ }
+ if (previewCanvas.width !== width || previewCanvas.height !== height) {
+ previewCanvas.width = width;
+ previewCanvas.height = height;
+ }
+ return { canvas: previewCanvas, gl: previewGl };
+}
+
+function getTestSource({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): CanvasImageSource | null {
+ if (
+ !testSourceCanvas ||
+ testSourceCanvas.width !== width ||
+ testSourceCanvas.height !== height
+ ) {
+ testSourceCanvas = createTestSource({ width, height });
+ }
+ return testSourceCanvas;
+}
+
+function applyWebGlEffect({
+ source,
+ width,
+ height,
+ passes,
+}: {
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+ passes: EffectPassData[];
+}): OffscreenCanvas | HTMLCanvasElement {
+ const { canvas: glCanvas, gl } = getOrCreatePreviewContext({ width, height });
+
+ applyMultiPassEffect({ context: gl, source, width, height, passes, programCache });
+
+ const outputCanvas = createOffscreenCanvas({ width, height });
+ const outputCtx = outputCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (outputCtx) {
+ outputCtx.drawImage(glCanvas, 0, 0, width, height);
+ }
+ return outputCanvas;
+}
+
+export function renderPreview({
+ effectType,
+ params,
+ targetCanvas,
+}: {
+ effectType: string;
+ params: EffectParamValues;
+ targetCanvas: HTMLCanvasElement;
+}): void {
+ const size = PREVIEW_SIZE;
+ const source = getTestSource({ width: size, height: size });
+ if (!source) return;
+
+ const definition = getEffect({ effectType });
+ const resolvedParams =
+ Object.keys(params).length > 0
+ ? params
+ : buildDefaultParams({ effectType });
+
+ const passes = definition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: resolvedParams,
+ width: size,
+ height: size,
+ }),
+ }));
+ const result = applyWebGlEffect({
+ source,
+ width: size,
+ height: size,
+ passes,
+ });
+
+ const targetCtx = targetCanvas.getContext(
+ "2d",
+ ) as CanvasRenderingContext2D | null;
+ if (targetCtx) {
+ targetCanvas.width = size;
+ targetCanvas.height = size;
+ targetCtx.drawImage(result, 0, 0, size, size);
+ }
+}
+
+export const effectPreviewService = {
+ renderPreview,
+ onPreviewImageReady,
+ PREVIEW_SIZE,
+};
diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts
deleted file mode 100644
index 504161d9..00000000
--- a/apps/web/src/services/renderer/nodes/blur-background-node.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import type { CanvasRenderer } from "../canvas-renderer";
-import { BaseNode } from "./base-node";
-
-export type BlurBackgroundNodeParams = {
- blurIntensity: number;
- contentNodes: BaseNode[];
-};
-
-export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
- private blurIntensity: number;
- private contentNodes: BaseNode[];
-
- constructor(params: BlurBackgroundNodeParams) {
- super(params);
- this.blurIntensity = params.blurIntensity;
- this.contentNodes = params.contentNodes;
- }
-
- async render({
- renderer,
- time,
- }: {
- renderer: CanvasRenderer;
- time: number;
- }): Promise<void> {
- let offscreen: OffscreenCanvas | HTMLCanvasElement;
- let offscreenCtx:
- | OffscreenCanvasRenderingContext2D
- | CanvasRenderingContext2D;
-
- try {
- offscreen = new OffscreenCanvas(renderer.width, renderer.height);
- const ctx = offscreen.getContext("2d");
- if (!ctx) {
- throw new Error("failed to get offscreen canvas context");
- }
- offscreenCtx = ctx;
- } catch {
- offscreen = document.createElement("canvas");
- offscreen.width = renderer.width;
- offscreen.height = renderer.height;
- const ctx = offscreen.getContext("2d");
- if (!ctx) {
- throw new Error("failed to get canvas context");
- }
- offscreenCtx = ctx;
- }
-
- const originalContext = renderer.context;
- renderer.context = offscreenCtx;
-
- for (const node of this.contentNodes) {
- await node.render({ renderer, time });
- }
-
- renderer.context = originalContext;
-
- const zoomScale = 1.4;
- const scaledWidth = renderer.width * zoomScale;
- const scaledHeight = renderer.height * zoomScale;
- const offsetX = (renderer.width - scaledWidth) / 2;
- const offsetY = (renderer.height - scaledHeight) / 2;
-
- renderer.context.save();
- renderer.context.filter = `blur(${this.blurIntensity}px)`;
- renderer.context.drawImage(
- offscreen as CanvasImageSource,
- offsetX,
- offsetY,
- scaledWidth,
- scaledHeight,
- );
- renderer.context.restore();
- }
-}
diff --git a/apps/web/src/services/renderer/nodes/composite-effect-node.ts b/apps/web/src/services/renderer/nodes/composite-effect-node.ts
new file mode 100644
index 00000000..3fbfffc1
--- /dev/null
+++ b/apps/web/src/services/renderer/nodes/composite-effect-node.ts
@@ -0,0 +1,77 @@
+import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
+import { getEffect } from "@/lib/effects";
+import type { EffectParamValues } from "@/types/effects";
+import { BaseNode } from "./base-node";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
+
+export type CompositeEffectNodeParams = {
+ contentNodes: BaseNode[];
+ effectType: string;
+ effectParams: EffectParamValues;
+ scale: number;
+};
+
+export class CompositeEffectNode extends BaseNode<CompositeEffectNodeParams> {
+ async render({
+ renderer,
+ time,
+ }: {
+ renderer: CanvasRenderer;
+ time: number;
+ }): Promise<void> {
+ const offscreen = createOffscreenCanvas({
+ width: renderer.width,
+ height: renderer.height,
+ });
+ const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
+ if (!offscreenCtx) {
+ throw new Error("failed to get offscreen canvas context");
+ }
+
+ const originalContext = renderer.context;
+ renderer.context = offscreenCtx;
+
+ for (const node of this.params.contentNodes) {
+ await node.render({ renderer, time });
+ }
+
+ renderer.context = originalContext;
+
+ const effectDefinition = getEffect({ effectType: this.params.effectType });
+ const scale = this.params.scale;
+ const scaledWidth = renderer.width * scale;
+ const scaledHeight = renderer.height * scale;
+ const offsetX = (renderer.width - scaledWidth) / 2;
+ const offsetY = (renderer.height - scaledHeight) / 2;
+
+ const passes = effectDefinition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: this.params.effectParams,
+ width: renderer.width,
+ height: renderer.height,
+ }),
+ }));
+ const effectResult = webglEffectRenderer.applyEffect({
+ source: offscreen as CanvasImageSource,
+ width: renderer.width,
+ height: renderer.height,
+ passes,
+ });
+
+ renderer.context.save();
+ renderer.context.drawImage(
+ effectResult,
+ 0,
+ 0,
+ renderer.width,
+ renderer.height,
+ offsetX,
+ offsetY,
+ scaledWidth,
+ scaledHeight,
+ );
+ renderer.context.restore();
+ }
+}
diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
new file mode 100644
index 00000000..3d84be2b
--- /dev/null
+++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
@@ -0,0 +1,82 @@
+import type { CanvasRenderer } from "../canvas-renderer";
+import { getEffect } from "@/lib/effects";
+import type { EffectParamValues } from "@/types/effects";
+import { BaseNode } from "./base-node";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
+
+const TIME_EPSILON = 1e-6;
+
+export type EffectLayerNodeParams = {
+ effectType: string;
+ effectParams: EffectParamValues;
+ timeOffset: number;
+ 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 = getEffect({
+ effectType: this.params.effectType,
+ });
+
+ const passes = effectDefinition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: this.params.effectParams,
+ width: renderer.width,
+ height: renderer.height,
+ }),
+ }));
+ const effectResult = webglEffectRenderer.applyEffect({
+ source,
+ width: renderer.width,
+ height: renderer.height,
+ passes,
+ });
+
+ 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();
+ }
+}
diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts
index 49e84ebe..6720a5e5 100644
--- a/apps/web/src/services/renderer/nodes/image-node.ts
+++ b/apps/web/src/services/renderer/nodes/image-node.ts
@@ -3,49 +3,88 @@ import { VisualNode, type VisualNodeParams } from "./visual-node";
export interface ImageNodeParams extends VisualNodeParams {
url: string;
+ maxSourceSize?: number;
}
-export class ImageNode extends VisualNode<ImageNodeParams> {
- private image?: HTMLImageElement;
- private readyPromise: Promise<void>;
+interface CachedImageSource {
+ source: HTMLImageElement | OffscreenCanvas;
+ width: number;
+ height: number;
+}
- constructor(params: ImageNodeParams) {
- super(params);
- this.readyPromise = this.load();
- }
+const imageSourceCache = new Map<string, Promise<CachedImageSource>>();
+
+function loadImageSource(
+ url: string,
+ maxSourceSize?: number,
+): Promise<CachedImageSource> {
+ const cacheKey = `${url}::${maxSourceSize ?? "full"}`;
- private async load() {
+ const cached = imageSourceCache.get(cacheKey);
+ if (cached) return cached;
+
+ const promise = (async (): Promise<CachedImageSource> => {
const image = new Image();
- this.image = image;
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("Image load failed"));
- image.src = this.params.url;
+ image.src = url;
});
- }
- async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
- await super.render({ renderer, time });
+ const naturalWidth = image.naturalWidth;
+ const naturalHeight = image.naturalHeight;
+ const exceedsLimit =
+ maxSourceSize &&
+ (naturalWidth > maxSourceSize || naturalHeight > maxSourceSize);
- if (!this.isInRange(time)) {
- return;
+ if (exceedsLimit) {
+ const scale = Math.min(
+ maxSourceSize / naturalWidth,
+ maxSourceSize / naturalHeight,
+ );
+ const scaledWidth = Math.round(naturalWidth * scale);
+ const scaledHeight = Math.round(naturalHeight * scale);
+
+ const offscreen = new OffscreenCanvas(scaledWidth, scaledHeight);
+ const ctx = offscreen.getContext("2d");
+
+ if (ctx) {
+ ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight);
+ return { source: offscreen, width: scaledWidth, height: scaledHeight };
+ }
}
- await this.readyPromise;
+ return { source: image, width: naturalWidth, height: naturalHeight };
+ })();
+
+ imageSourceCache.set(cacheKey, promise);
+ 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.image) {
+ if (!this.isInRange({ time })) {
return;
}
- const mediaW = this.image.naturalWidth || renderer.width;
- const mediaH = this.image.naturalHeight || renderer.height;
+ const { source, width, height } = await this.cachedSource;
this.renderVisual({
renderer,
- source: this.image,
- sourceWidth: mediaW,
- sourceHeight: mediaH,
+ source,
+ sourceWidth: width || renderer.width,
+ sourceHeight: height || renderer.height,
+ timelineTime: time,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index a2fb0170..da271685 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -6,49 +6,63 @@ export interface StickerNodeParams extends VisualNodeParams {
stickerId: string;
}
-export class StickerNode extends VisualNode<StickerNodeParams> {
- private image?: HTMLImageElement;
- private readyPromise: Promise<void>;
+interface CachedStickerSource {
+ source: HTMLImageElement;
+ width: number;
+ height: number;
+}
- constructor(params: StickerNodeParams) {
- super(params);
- this.readyPromise = this.load();
- }
+const stickerSourceCache = new Map<string, Promise<CachedStickerSource>>();
- private async load() {
- const image = new Image();
- this.image = image;
+function loadStickerSource(stickerId: string): Promise<CachedStickerSource> {
+ const cached = stickerSourceCache.get(stickerId);
+ if (cached) return cached;
+
+ const promise = (async (): Promise<CachedStickerSource> => {
const url = resolveStickerId({
- stickerId: this.params.stickerId,
+ stickerId,
options: { width: 200, height: 200 },
});
+ const image = new Image();
+
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () =>
- reject(new Error(`Failed to load sticker: ${this.params.stickerId}`));
+ reject(new Error(`Failed to load sticker: ${stickerId}`));
image.src = url;
});
+
+ return { source: image, width: 200, height: 200 };
+ })();
+
+ stickerSourceCache.set(stickerId, promise);
+ return promise;
+}
+
+export class StickerNode extends VisualNode<StickerNodeParams> {
+ private cachedSource: Promise<CachedStickerSource>;
+
+ constructor(params: StickerNodeParams) {
+ super(params);
+ this.cachedSource = loadStickerSource(params.stickerId);
}
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
- if (!this.isInRange(time)) {
+ if (!this.isInRange({ time })) {
return;
}
- await this.readyPromise;
-
- if (!this.image) {
- return;
- }
+ const { source, width, height } = await this.cachedSource;
this.renderVisual({
renderer,
- source: this.image,
- sourceWidth: 200,
- sourceHeight: 200,
+ source,
+ sourceWidth: width,
+ sourceHeight: height,
+ timelineTime: time,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index b7e36d64..0ddb4eae 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -2,9 +2,21 @@ import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
import {
+ DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
+import {
+ getMetricAscent,
+ getMetricDescent,
+ getTextBackgroundRect,
+ measureTextBlock,
+} from "@/lib/text/layout";
+import {
+ getElementLocalTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
function scaleFontSize({
fontSize,
@@ -20,65 +32,6 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
return `"${fontFamily.replace(/"/g, '\\"')}"`;
}
-function getMetricAscent({
- metrics,
- fallback,
-}: {
- metrics: TextMetrics;
- fallback: number;
-}): number {
- return metrics.actualBoundingBoxAscent ?? fallback * 0.8;
-}
-
-function getMetricDescent({
- metrics,
- fallback,
-}: {
- metrics: TextMetrics;
- fallback: number;
-}): number {
- return metrics.actualBoundingBoxDescent ?? fallback * 0.2;
-}
-
-interface TextBlockMeasurement {
- visualCenterOffset: number;
- height: number;
- maxWidth: number;
-}
-
-function measureTextBlock({
- lineMetrics,
- lineHeightPx,
- fallbackFontSize,
-}: {
- lineMetrics: TextMetrics[];
- lineHeightPx: number;
- fallbackFontSize: number;
-}): TextBlockMeasurement {
- let top = Number.POSITIVE_INFINITY;
- let bottom = Number.NEGATIVE_INFINITY;
- let maxWidth = 0;
-
- for (let i = 0; i < lineMetrics.length; i++) {
- const metrics = lineMetrics[i];
- const y = i * lineHeightPx;
- top = Math.min(
- top,
- y - getMetricAscent({ metrics, fallback: fallbackFontSize }),
- );
- bottom = Math.max(
- bottom,
- y + getMetricDescent({ metrics, fallback: fallbackFontSize }),
- );
- maxWidth = Math.max(maxWidth, metrics.width);
- }
-
- const height = bottom - top;
- const visualCenterOffset = (top + bottom) / 2;
-
- return { visualCenterOffset, height, maxWidth };
-}
-
function drawTextDecoration({
ctx,
textDecoration,
@@ -99,8 +52,8 @@ function drawTextDecoration({
if (textDecoration === "none" || !textDecoration) return;
const thickness = Math.max(1, scaledFontSize * 0.07);
- const ascent = getMetricAscent({ metrics, fallback: scaledFontSize });
- const descent = getMetricDescent({ metrics, fallback: scaledFontSize });
+ const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
+ const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
let xStart = -lineWidth / 2;
if (textAlign === "left") xStart = 0;
@@ -138,16 +91,28 @@ export class TextNode extends BaseNode<TextNodeParams> {
renderer.context.save();
- const x = this.params.transform.position.x + this.params.canvasCenter.x;
- const y = this.params.transform.position.y + this.params.canvasCenter.y;
+ 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,
+ });
+ const x = transform.position.x + this.params.canvasCenter.x;
+ const y = transform.position.y + this.params.canvasCenter.y;
renderer.context.translate(x, y);
- renderer.context.scale(
- this.params.transform.scale,
- this.params.transform.scale,
- );
- if (this.params.transform.rotate) {
- renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180);
+ renderer.context.scale(transform.scale, transform.scale);
+ if (transform.rotate) {
+ renderer.context.rotate((transform.rotate * Math.PI) / 180);
}
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
@@ -171,6 +136,7 @@ export class TextNode extends BaseNode<TextNodeParams> {
const lines = this.params.content.split("\n");
const lineHeightPx = scaledFontSize * lineHeight;
+ const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const baseline = this.params.textBaseline ?? "middle";
renderer.context.textBaseline = baseline;
@@ -189,24 +155,33 @@ export class TextNode extends BaseNode<TextNodeParams> {
? this.params.blendMode
: "source-over"
) as GlobalCompositeOperation;
- renderer.context.globalAlpha = this.params.opacity;
+ renderer.context.globalAlpha = opacity;
- if (this.params.backgroundColor && lineCount > 0) {
- const padX = 8;
- const padY = 4;
- renderer.context.fillStyle = this.params.backgroundColor;
- let bgLeft = -block.maxWidth / 2;
- if (renderer.context.textAlign === "left") bgLeft = 0;
- if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth;
-
- renderer.context.fillRect(
- bgLeft - padX,
- -block.height / 2 - padY,
- block.maxWidth + padX * 2,
- block.height + padY * 2,
- );
-
- renderer.context.fillStyle = this.params.color;
+ if (
+ this.params.background.color &&
+ this.params.background.color !== "transparent" &&
+ lineCount > 0
+ ) {
+ const { color, cornerRadius = 0 } = this.params.background;
+ const backgroundRect = getTextBackgroundRect({
+ textAlign: this.params.textAlign,
+ block,
+ background: this.params.background,
+ fontSizeRatio,
+ });
+ if (backgroundRect) {
+ renderer.context.fillStyle = color;
+ renderer.context.beginPath();
+ renderer.context.roundRect(
+ backgroundRect.left,
+ backgroundRect.top,
+ backgroundRect.width,
+ backgroundRect.height,
+ cornerRadius,
+ );
+ renderer.context.fill();
+ renderer.context.fillStyle = this.params.color;
+ }
}
for (let i = 0; i < lineCount; i++) {
diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts
index 8e2ddccd..503c9c03 100644
--- a/apps/web/src/services/renderer/nodes/video-node.ts
+++ b/apps/web/src/services/renderer/nodes/video-node.ts
@@ -12,11 +12,11 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
- if (!this.isInRange(time)) {
+ if (!this.isInRange({ time })) {
return;
}
- const videoTime = this.getLocalTime(time);
+ const videoTime = this.getSourceLocalTime({ time });
const frame = await videoCache.getFrameAt({
mediaId: this.params.mediaId,
file: this.params.file,
@@ -29,6 +29,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
source: frame.canvas,
sourceWidth: frame.canvas.width,
sourceHeight: frame.canvas.height,
+ timelineTime: time,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 7179369e..15a7b081 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -1,9 +1,18 @@
import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
import { BaseNode } from "./base-node";
+import type { Effect } from "@/types/effects";
import type { BlendMode } from "@/types/rendering";
import type { Transform } from "@/types/timeline";
-
-const VISUAL_EPSILON = 1 / 1000;
+import type { ElementAnimations } from "@/types/animation";
+import {
+ getElementLocalTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { getEffect } from "@/lib/effects";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
export interface VisualNodeParams {
duration: number;
@@ -11,21 +20,31 @@ export interface VisualNodeParams {
trimStart: number;
trimEnd: number;
transform: Transform;
+ animations?: ElementAnimations;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export abstract class VisualNode<
Params extends VisualNodeParams = VisualNodeParams,
> extends BaseNode<Params> {
- protected getLocalTime(time: number): number {
+ protected getSourceLocalTime({ time }: { time: number }): number {
return time - this.params.timeOffset + this.params.trimStart;
}
- protected isInRange(time: number): boolean {
- const localTime = this.getLocalTime(time);
+ 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 = this.getSourceLocalTime({ time });
return (
- localTime >= this.params.trimStart - VISUAL_EPSILON &&
+ localTime >= this.params.trimStart - TIME_EPSILON_SECONDS &&
localTime < this.params.trimStart + this.params.duration
);
}
@@ -35,15 +54,27 @@ export abstract class VisualNode<
source,
sourceWidth,
sourceHeight,
+ timelineTime,
}: {
renderer: CanvasRenderer;
source: CanvasImageSource;
sourceWidth: number;
sourceHeight: number;
+ timelineTime: number;
}): void {
renderer.context.save();
- const { transform, opacity } = this.params;
+ 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,
@@ -68,7 +99,58 @@ export abstract class VisualNode<
renderer.context.translate(-centerX, -centerY);
}
- renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ const enabledEffects =
+ this.params.effects?.filter((effect) => effect.enabled) ?? [];
+
+ if (enabledEffects.length === 0) {
+ renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ renderer.context.restore();
+ return;
+ }
+
+ const elementCanvas = createOffscreenCanvas({
+ width: Math.round(scaledWidth),
+ height: Math.round(scaledHeight),
+ });
+ const elementCtx = elementCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!elementCtx) {
+ renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ renderer.context.restore();
+ return;
+ }
+
+ elementCtx.drawImage(source, 0, 0, scaledWidth, scaledHeight);
+
+ let currentResult: CanvasImageSource = elementCanvas;
+
+ for (const effect of enabledEffects) {
+ const definition = getEffect({ effectType: effect.type });
+ const passes = definition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: effect.params,
+ width: scaledWidth,
+ height: scaledHeight,
+ }),
+ }));
+ currentResult = webglEffectRenderer.applyEffect({
+ source: currentResult,
+ width: Math.round(scaledWidth),
+ height: Math.round(scaledHeight),
+ passes,
+ });
+ }
+
+ renderer.context.drawImage(
+ currentResult,
+ x,
+ y,
+ scaledWidth,
+ scaledHeight,
+ );
renderer.context.restore();
}
}
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index ee7f3d6c..695e077f 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -6,48 +6,59 @@ import { ImageNode } from "./nodes/image-node";
import { TextNode } from "./nodes/text-node";
import { StickerNode } from "./nodes/sticker-node";
import { ColorNode } from "./nodes/color-node";
-import { BlurBackgroundNode } from "./nodes/blur-background-node";
+import { CompositeEffectNode } from "./nodes/composite-effect-node";
+import { EffectLayerNode } from "./nodes/effect-layer-node";
+import type { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/types/project";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { isMainTrack } from "@/lib/timeline";
-export type BuildSceneParams = {
- canvasSize: TCanvasSize;
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
- duration: number;
- background: TBackground;
-};
-
-export function buildScene(params: BuildSceneParams) {
- const { tracks, mediaAssets, duration, canvasSize, background } = params;
-
- const rootNode = new RootNode({ duration });
- const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
-
- const visibleTracks = tracks.filter(
- (track) => !("hidden" in track && track.hidden),
- );
+const PREVIEW_MAX_IMAGE_SIZE = 2048;
+const BLUR_BACKGROUND_ZOOM_SCALE = 1.4;
- const orderedTracksTopToBottom = [
- ...visibleTracks.filter((track) => !isMainTrack(track)),
- ...visibleTracks.filter((track) => isMainTrack(track)),
- ];
-
- const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
+function getVisibleSortedElements({
+ track,
+}: {
+ track: TimelineTrack;
+}) {
+ return track.elements
+ .filter((element) => !("hidden" in element && element.hidden))
+ .slice()
+ .sort((a, b) => {
+ if (a.startTime !== b.startTime) return a.startTime - b.startTime;
+ return a.id.localeCompare(b.id);
+ });
+}
- const contentNodes = [];
+function buildTrackNodes({
+ tracks,
+ mediaMap,
+ canvasSize,
+ isPreview,
+}: {
+ tracks: TimelineTrack[];
+ mediaMap: Map<string, MediaAsset>;
+ canvasSize: TCanvasSize;
+ isPreview?: boolean;
+}): BaseNode[] {
+ const nodes: BaseNode[] = [];
- for (const track of orderedTracksBottomToTop) {
- const elements = track.elements
- .filter((element) => !("hidden" in element && element.hidden))
- .slice()
- .sort((a, b) => {
- if (a.startTime !== b.startTime) return a.startTime - b.startTime;
- return a.id.localeCompare(b.id);
- });
+ for (const track of tracks) {
+ const elements = getVisibleSortedElements({ track });
for (const element of elements) {
+ if (element.type === "effect") {
+ nodes.push(
+ new EffectLayerNode({
+ effectType: element.effectType,
+ effectParams: element.params,
+ timeOffset: element.startTime,
+ duration: element.duration,
+ }),
+ );
+ continue;
+ }
+
if (element.type === "video" || element.type === "image") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset?.file || !mediaAsset?.url) {
@@ -55,7 +66,7 @@ export function buildScene(params: BuildSceneParams) {
}
if (mediaAsset.type === "video") {
- contentNodes.push(
+ nodes.push(
new VideoNode({
mediaId: mediaAsset.id,
url: mediaAsset.url,
@@ -65,13 +76,15 @@ export function buildScene(params: BuildSceneParams) {
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
+ animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
+ effects: element.effects,
}),
);
}
if (mediaAsset.type === "image") {
- contentNodes.push(
+ nodes.push(
new ImageNode({
url: mediaAsset.url,
duration: element.duration,
@@ -79,26 +92,32 @@ export function buildScene(params: BuildSceneParams) {
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
+ animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
+ effects: element.effects,
+ ...(isPreview && {
+ maxSourceSize: PREVIEW_MAX_IMAGE_SIZE,
+ }),
}),
);
}
}
if (element.type === "text") {
- contentNodes.push(
+ nodes.push(
new TextNode({
...element,
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
canvasHeight: canvasSize.height,
textBaseline: "middle",
+ effects: element.effects,
}),
);
}
if (element.type === "sticker") {
- contentNodes.push(
+ nodes.push(
new StickerNode({
stickerId: element.stickerId,
duration: element.duration,
@@ -106,31 +125,77 @@ export function buildScene(params: BuildSceneParams) {
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
+ animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
+ effects: element.effects,
}),
);
}
}
}
+ return nodes;
+}
+
+export type BuildSceneParams = {
+ canvasSize: TCanvasSize;
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ duration: number;
+ background: TBackground;
+ isPreview?: boolean;
+};
+
+export function buildScene({
+ canvasSize,
+ tracks,
+ mediaAssets,
+ duration,
+ background,
+ isPreview,
+}: BuildSceneParams) {
+ const rootNode = new RootNode({ duration });
+ const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
+
+ const visibleTracks = tracks.filter(
+ (track) => !("hidden" in track && track.hidden),
+ );
+
+ const orderedTracksTopToBottom = [
+ ...visibleTracks.filter((track) => !isMainTrack(track)),
+ ...visibleTracks.filter((track) => isMainTrack(track)),
+ ];
+
+ const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
+
+ const allNodes = buildTrackNodes({
+ tracks: orderedTracksBottomToTop,
+ mediaMap,
+ canvasSize,
+ isPreview,
+ });
+
if (background.type === "blur") {
rootNode.add(
- new BlurBackgroundNode({
- blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
- contentNodes,
+ new CompositeEffectNode({
+ contentNodes: allNodes.filter(
+ (node) => !(node instanceof EffectLayerNode),
+ ),
+ effectType: "blur",
+ effectParams: {
+ intensity:
+ background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
+ },
+ scale: BLUR_BACKGROUND_ZOOM_SCALE,
}),
);
- for (const node of contentNodes) {
- rootNode.add(node);
- }
- } else {
- if (background.type === "color" && background.color !== "transparent") {
- rootNode.add(new ColorNode({ color: background.color }));
- }
- for (const node of contentNodes) {
- rootNode.add(node);
- }
+ } else if (background.type === "color" && background.color !== "transparent") {
+ rootNode.add(new ColorNode({ color: background.color }));
+ }
+
+ for (const node of allNodes) {
+ rootNode.add(node);
}
return rootNode;
diff --git a/apps/web/src/services/renderer/scene-exporter.ts b/apps/web/src/services/renderer/scene-exporter.ts
index 39e94e55..a715efe7 100644
--- a/apps/web/src/services/renderer/scene-exporter.ts
+++ b/apps/web/src/services/renderer/scene-exporter.ts
@@ -13,11 +13,9 @@ import {
QUALITY_VERY_HIGH,
} from "mediabunny";
import type { RootNode } from "./nodes/root-node";
+import type { ExportFormat, ExportQuality } from "@/types/export";
import { CanvasRenderer } from "./canvas-renderer";
-export type ExportFormat = "mp4" | "webm";
-export type ExportQuality = "low" | "medium" | "high" | "very_high";
-
type ExportParams = {
width: number;
height: number;
diff --git a/apps/web/src/services/renderer/webgl-effect-renderer.ts b/apps/web/src/services/renderer/webgl-effect-renderer.ts
new file mode 100644
index 00000000..ad5cd40e
--- /dev/null
+++ b/apps/web/src/services/renderer/webgl-effect-renderer.ts
@@ -0,0 +1,73 @@
+import { createOffscreenCanvas } from "./canvas-utils";
+import { applyMultiPassEffect } from "./webgl-utils";
+import type { EffectPassData } from "./webgl-utils";
+
+export interface ApplyEffectParams {
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+ passes: EffectPassData[];
+}
+
+let gl: WebGLRenderingContext | null = null;
+let canvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+const programCache = new Map<string, WebGLProgram>();
+
+function getOrCreateCanvas({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): OffscreenCanvas | HTMLCanvasElement {
+ if (!canvas) {
+ canvas = createOffscreenCanvas({ width, height });
+ gl = canvas.getContext("webgl", {
+ premultipliedAlpha: false,
+ }) as WebGLRenderingContext | null;
+ if (!gl) {
+ throw new Error("WebGL not supported");
+ }
+ }
+ if (canvas.width !== width || canvas.height !== height) {
+ canvas.width = width;
+ canvas.height = height;
+ }
+ return canvas;
+}
+
+function applyEffect({
+ source,
+ width,
+ height,
+ passes,
+}: ApplyEffectParams): OffscreenCanvas | HTMLCanvasElement {
+ const targetCanvas = getOrCreateCanvas({ width, height });
+ const context = gl;
+ if (!context) {
+ throw new Error("WebGL context not initialized");
+ }
+
+ applyMultiPassEffect({
+ context,
+ source,
+ width,
+ height,
+ passes,
+ programCache,
+ });
+
+ const outputCanvas = createOffscreenCanvas({ width, height });
+ const outputCtx = outputCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (outputCtx) {
+ outputCtx.drawImage(targetCanvas, 0, 0, width, height);
+ }
+ return outputCanvas;
+}
+
+export const webglEffectRenderer = {
+ applyEffect,
+};
diff --git a/apps/web/src/services/renderer/webgl-utils.ts b/apps/web/src/services/renderer/webgl-utils.ts
new file mode 100644
index 00000000..834c652e
--- /dev/null
+++ b/apps/web/src/services/renderer/webgl-utils.ts
@@ -0,0 +1,298 @@
+import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl";
+
+export interface EffectPassData {
+ fragmentShader: string;
+ uniforms: Record<string, number | number[]>;
+}
+
+export const QUAD_POSITIONS = new Float32Array([
+ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1,
+]);
+
+export function compileProgram({
+ context,
+ fragmentShaderSource,
+ programCache,
+}: {
+ context: WebGLRenderingContext;
+ fragmentShaderSource: string;
+ programCache: Map<string, WebGLProgram>;
+}): WebGLProgram {
+ const cached = programCache.get(fragmentShaderSource);
+ if (cached) {
+ return cached;
+ }
+ const vertexShader = compileShader({
+ context,
+ source: VERTEX_SHADER_SOURCE,
+ type: context.VERTEX_SHADER,
+ });
+ const fragmentShader = compileShader({
+ context,
+ source: fragmentShaderSource,
+ type: context.FRAGMENT_SHADER,
+ });
+ const program = context.createProgram();
+ if (!program) {
+ throw new Error("Failed to create WebGL program");
+ }
+ context.attachShader(program, vertexShader);
+ context.attachShader(program, fragmentShader);
+ context.linkProgram(program);
+ if (!context.getProgramParameter(program, context.LINK_STATUS)) {
+ const info = context.getProgramInfoLog(program);
+ context.deleteProgram(program);
+ throw new Error(`WebGL program link failed: ${info}`);
+ }
+ context.deleteShader(vertexShader);
+ context.deleteShader(fragmentShader);
+ programCache.set(fragmentShaderSource, program);
+ return program;
+}
+
+export function compileShader({
+ context,
+ source,
+ type,
+}: {
+ context: WebGLRenderingContext;
+ source: string;
+ type: number;
+}): WebGLShader {
+ const shader = context.createShader(type);
+ if (!shader) {
+ throw new Error("Failed to create WebGL shader");
+ }
+ context.shaderSource(shader, source);
+ context.compileShader(shader);
+ if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) {
+ const info = context.getShaderInfoLog(shader);
+ context.deleteShader(shader);
+ throw new Error(`WebGL shader compile failed: ${info}`);
+ }
+ return shader;
+}
+
+export function createTexture({
+ context,
+ source,
+}: {
+ context: WebGLRenderingContext;
+ source: CanvasImageSource;
+}): WebGLTexture {
+ const texture = context.createTexture();
+ if (!texture) {
+ throw new Error("Failed to create WebGL texture");
+ }
+ context.activeTexture(context.TEXTURE0);
+ context.bindTexture(context.TEXTURE_2D, texture);
+ context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, 1);
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_S,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_T,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MIN_FILTER,
+ context.LINEAR,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MAG_FILTER,
+ context.LINEAR,
+ );
+ context.texImage2D(
+ context.TEXTURE_2D,
+ 0,
+ context.RGBA,
+ context.RGBA,
+ context.UNSIGNED_BYTE,
+ source as TexImageSource,
+ );
+ return texture;
+}
+
+export function setUniforms({
+ context,
+ program,
+ uniforms,
+}: {
+ context: WebGLRenderingContext;
+ program: WebGLProgram;
+ uniforms: Record<string, number | number[]>;
+}): void {
+ for (const [name, value] of Object.entries(uniforms)) {
+ const location = context.getUniformLocation(program, name);
+ if (location === null) continue;
+
+ if (typeof value === "number") {
+ context.uniform1f(location, value);
+ } else if (Array.isArray(value)) {
+ if (value.length === 2) {
+ context.uniform2fv(location, new Float32Array(value));
+ } else if (value.length === 3) {
+ context.uniform3fv(location, new Float32Array(value));
+ } else if (value.length === 4) {
+ context.uniform4fv(location, new Float32Array(value));
+ }
+ }
+ }
+}
+
+export function drawFullscreenQuad({
+ context,
+ program,
+ width,
+ height,
+}: {
+ context: WebGLRenderingContext;
+ program: WebGLProgram;
+ width: number;
+ height: number;
+}): void {
+ const positionLocation = context.getAttribLocation(program, "a_position");
+ const buffer = context.createBuffer();
+ context.bindBuffer(context.ARRAY_BUFFER, buffer);
+ context.bufferData(context.ARRAY_BUFFER, QUAD_POSITIONS, context.STATIC_DRAW);
+ context.enableVertexAttribArray(positionLocation);
+ context.vertexAttribPointer(positionLocation, 2, context.FLOAT, false, 0, 0);
+
+ context.viewport(0, 0, width, height);
+ context.clearColor(0, 0, 0, 0);
+ context.clear(context.COLOR_BUFFER_BIT);
+ context.drawArrays(context.TRIANGLES, 0, 6);
+}
+
+export function createFramebufferTexture({
+ context,
+ width,
+ height,
+}: {
+ context: WebGLRenderingContext;
+ width: number;
+ height: number;
+}): { texture: WebGLTexture; framebuffer: WebGLFramebuffer } {
+ const texture = context.createTexture();
+ if (!texture) throw new Error("Failed to create framebuffer texture");
+ context.bindTexture(context.TEXTURE_2D, texture);
+ context.texImage2D(
+ context.TEXTURE_2D,
+ 0,
+ context.RGBA,
+ width,
+ height,
+ 0,
+ context.RGBA,
+ context.UNSIGNED_BYTE,
+ null,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_S,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_T,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MIN_FILTER,
+ context.LINEAR,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MAG_FILTER,
+ context.LINEAR,
+ );
+ context.bindTexture(context.TEXTURE_2D, null);
+
+ const framebuffer = context.createFramebuffer();
+ if (!framebuffer) throw new Error("Failed to create framebuffer");
+ context.bindFramebuffer(context.FRAMEBUFFER, framebuffer);
+ context.framebufferTexture2D(
+ context.FRAMEBUFFER,
+ context.COLOR_ATTACHMENT0,
+ context.TEXTURE_2D,
+ texture,
+ 0,
+ );
+ context.bindFramebuffer(context.FRAMEBUFFER, null);
+
+ return { texture, framebuffer };
+}
+
+export function applyMultiPassEffect({
+ context,
+ source,
+ width,
+ height,
+ passes,
+ programCache,
+}: {
+ context: WebGLRenderingContext;
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+ passes: EffectPassData[];
+ programCache: Map<string, WebGLProgram>;
+}): void {
+ const sourceTexture = createTexture({ context, source });
+ let currentTexture: WebGLTexture = sourceTexture;
+
+ const intermediates: Array<{
+ texture: WebGLTexture;
+ framebuffer: WebGLFramebuffer;
+ }> = [];
+ for (let i = 0; i < passes.length - 1; i++) {
+ intermediates.push(createFramebufferTexture({ context, width, height }));
+ }
+
+ for (let i = 0; i < passes.length; i++) {
+ const pass = passes[i];
+ const program = compileProgram({
+ context,
+ fragmentShaderSource: pass.fragmentShader,
+ programCache,
+ });
+ const isLastPass = i === passes.length - 1;
+ const targetFramebuffer = isLastPass ? null : intermediates[i].framebuffer;
+
+ context.bindFramebuffer(context.FRAMEBUFFER, targetFramebuffer);
+ // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook
+ context.useProgram(program);
+ context.activeTexture(context.TEXTURE0);
+ context.bindTexture(context.TEXTURE_2D, currentTexture);
+
+ const uTextureLocation = context.getUniformLocation(program, "u_texture");
+ if (uTextureLocation) {
+ context.uniform1i(uTextureLocation, 0);
+ }
+
+ setUniforms({
+ context,
+ program,
+ uniforms: { ...pass.uniforms, u_resolution: [width, height] },
+ });
+ drawFullscreenQuad({ context, program, width, height });
+
+ if (!isLastPass) {
+ currentTexture = intermediates[i].texture;
+ }
+ }
+
+ context.deleteTexture(sourceTexture);
+ for (const intermediate of intermediates) {
+ context.deleteTexture(intermediate.texture);
+ context.deleteFramebuffer(intermediate.framebuffer);
+ }
+ context.bindTexture(context.TEXTURE_2D, null);
+ context.bindFramebuffer(context.FRAMEBUFFER, null);
+}