summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer/compositor/frame-descriptor.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-13 04:41:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-13 05:08:29 +0200
commitcad88ee4d1c9e06e7529b88ef5e5d3fdf3f89149 (patch)
tree0bfc1fcb18b3da4969afa2836bb65e6a5e25d123 /apps/web/src/services/renderer/compositor/frame-descriptor.ts
parent36efdf66e707c818b81009acf8a00630a0309ca1 (diff)
downloadopencut-cad88ee4d1c9e06e7529b88ef5e5d3fdf3f89149.tar.gz
refactor: rendering pipeline
Made-with: Cursor
Diffstat (limited to 'apps/web/src/services/renderer/compositor/frame-descriptor.ts')
-rw-r--r--apps/web/src/services/renderer/compositor/frame-descriptor.ts559
1 files changed, 559 insertions, 0 deletions
diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
new file mode 100644
index 00000000..8b8201d5
--- /dev/null
+++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
@@ -0,0 +1,559 @@
+import { drawCssBackground } from "@/lib/gradients";
+import { masksRegistry } from "@/lib/masks";
+import type { AnyBaseNode } from "../nodes/base-node";
+import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
+import { BlurBackgroundNode } from "../nodes/blur-background-node";
+import { ColorNode } from "../nodes/color-node";
+import { EffectLayerNode } from "../nodes/effect-layer-node";
+import { GraphicNode, type ResolvedGraphicNodeState } from "../nodes/graphic-node";
+import { ImageNode } from "../nodes/image-node";
+import { RootNode } from "../nodes/root-node";
+import { StickerNode } from "../nodes/sticker-node";
+import { renderTextToContext, TextNode } from "../nodes/text-node";
+import { VideoNode } from "../nodes/video-node";
+import type { ResolvedVisualSourceNodeState } from "../nodes/visual-node";
+import type {
+ FrameDescriptor,
+ FrameItemDescriptor,
+ LayerMaskDescriptor,
+ QuadTransformDescriptor,
+} from "./types";
+import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
+
+export type TextureUploadDescriptor = {
+ id: string;
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+};
+
+export async function buildFrameDescriptor({
+ node,
+ renderer,
+}: {
+ node: AnyBaseNode;
+ renderer: CanvasRenderer;
+}): Promise<{
+ frame: FrameDescriptor;
+ textures: TextureUploadDescriptor[];
+}> {
+ const items: FrameItemDescriptor[] = [];
+ const textures = new Map<string, TextureUploadDescriptor>();
+
+ await collectNode({
+ node,
+ renderer,
+ path: "root",
+ items,
+ textures,
+ });
+
+ return {
+ frame: {
+ width: renderer.width,
+ height: renderer.height,
+ clear: {
+ color: [0, 0, 0, 1],
+ },
+ items,
+ },
+ textures: [...textures.values()],
+ };
+}
+
+async function collectNode({
+ node,
+ renderer,
+ path,
+ items,
+ textures,
+}: {
+ node: AnyBaseNode;
+ renderer: CanvasRenderer;
+ path: string;
+ items: FrameItemDescriptor[];
+ textures: Map<string, TextureUploadDescriptor>;
+}): Promise<void> {
+ if (node instanceof RootNode) {
+ for (let index = 0; index < node.children.length; index++) {
+ await collectNode({
+ node: node.children[index],
+ renderer,
+ path: `${path}:${index}`,
+ items,
+ textures,
+ });
+ }
+ return;
+ }
+
+ if (node instanceof ColorNode) {
+ const textureId = `${path}:color`;
+ const canvas = createOffscreenCanvas({
+ width: renderer.width,
+ height: renderer.height,
+ });
+ const ctx = canvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!ctx) return;
+ if (/gradient\(/i.test(node.params.color)) {
+ drawCssBackground({
+ ctx,
+ width: renderer.width,
+ height: renderer.height,
+ css: node.params.color,
+ });
+ } else {
+ ctx.fillStyle = node.params.color;
+ ctx.fillRect(0, 0, renderer.width, renderer.height);
+ }
+ textures.set(textureId, {
+ id: textureId,
+ source: canvas,
+ width: renderer.width,
+ height: renderer.height,
+ });
+ items.push({
+ type: "layer",
+ textureId,
+ transform: fullCanvasTransform(renderer),
+ opacity: 1,
+ blendMode: "normal",
+ effectPassGroups: [],
+ mask: null,
+ });
+ return;
+ }
+
+ if (node instanceof EffectLayerNode) {
+ if (!node.resolved || node.resolved.passes.length === 0) {
+ return;
+ }
+ items.push({
+ type: "sceneEffect",
+ effectPassGroups: [node.resolved.passes],
+ });
+ return;
+ }
+
+ if (node instanceof BlurBackgroundNode) {
+ if (!node.resolved) {
+ return;
+ }
+ const textureId = `${path}:blur-background`;
+ const backdropCanvas = createOffscreenCanvas({
+ width: renderer.width,
+ height: renderer.height,
+ });
+ const backdropCtx = backdropCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!backdropCtx) return;
+ const { backdropSource, passes } = node.resolved;
+ 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;
+ backdropCtx.drawImage(
+ backdropSource.source,
+ offsetX,
+ offsetY,
+ scaledWidth,
+ scaledHeight,
+ );
+ textures.set(textureId, {
+ id: textureId,
+ source: backdropCanvas,
+ width: renderer.width,
+ height: renderer.height,
+ });
+ items.push({
+ type: "layer",
+ textureId,
+ transform: fullCanvasTransform(renderer),
+ opacity: 1,
+ blendMode: "normal",
+ effectPassGroups: [passes],
+ mask: null,
+ });
+ return;
+ }
+
+ if (
+ node instanceof VideoNode ||
+ node instanceof ImageNode ||
+ node instanceof StickerNode ||
+ node instanceof GraphicNode
+ ) {
+ await collectVisualSourceNode({
+ node,
+ renderer,
+ path,
+ items,
+ textures,
+ });
+ return;
+ }
+
+ if (node instanceof TextNode) {
+ collectTextNode({
+ node,
+ renderer,
+ path,
+ items,
+ textures,
+ });
+ }
+}
+
+async function collectVisualSourceNode({
+ node,
+ renderer,
+ path,
+ items,
+ textures,
+}: {
+ node: VideoNode | ImageNode | StickerNode | GraphicNode;
+ renderer: CanvasRenderer;
+ path: string;
+ items: FrameItemDescriptor[];
+ textures: Map<string, TextureUploadDescriptor>;
+}) {
+ if (!node.resolved) {
+ return;
+ }
+
+ const source =
+ node instanceof GraphicNode
+ ? node.getSource({ resolvedParams: node.resolved.resolvedParams })
+ : node.resolved.source;
+ if (!source) {
+ return;
+ }
+
+ const sourceWidth =
+ node instanceof GraphicNode
+ ? DEFAULT_GRAPHIC_SOURCE_SIZE
+ : (node.resolved as ResolvedVisualSourceNodeState).sourceWidth;
+ const sourceHeight =
+ node instanceof GraphicNode
+ ? DEFAULT_GRAPHIC_SOURCE_SIZE
+ : (node.resolved as ResolvedVisualSourceNodeState).sourceHeight;
+
+ const textureId = `${path}:source`;
+ textures.set(textureId, {
+ id: textureId,
+ source,
+ width: sourceWidth,
+ height: sourceHeight,
+ });
+
+ const transform = computeVisualTransform({
+ renderer,
+ resolved: node.resolved,
+ sourceWidth,
+ sourceHeight,
+ });
+ const { mask, strokeLayer } = buildMaskArtifacts({
+ node,
+ renderer,
+ path,
+ transform,
+ textures,
+ });
+
+ items.push({
+ type: "layer",
+ textureId,
+ transform,
+ opacity: node.resolved.opacity,
+ blendMode: node.params.blendMode ?? "normal",
+ effectPassGroups: node.resolved.effectPasses,
+ mask,
+ });
+ if (strokeLayer) {
+ items.push(strokeLayer);
+ }
+}
+
+function collectTextNode({
+ node,
+ renderer,
+ path,
+ items,
+ textures,
+}: {
+ node: TextNode;
+ renderer: CanvasRenderer;
+ path: string;
+ items: FrameItemDescriptor[];
+ textures: Map<string, TextureUploadDescriptor>;
+}) {
+ if (!node.resolved) {
+ return;
+ }
+
+ const textureId = `${path}:text`;
+ const canvas = createOffscreenCanvas({
+ width: renderer.width,
+ height: renderer.height,
+ });
+ const ctx = canvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!ctx) {
+ return;
+ }
+
+ renderTextToContext({
+ node,
+ ctx,
+ });
+
+ textures.set(textureId, {
+ id: textureId,
+ source: canvas,
+ width: renderer.width,
+ height: renderer.height,
+ });
+ items.push({
+ type: "layer",
+ textureId,
+ transform: fullCanvasTransform(renderer),
+ opacity: node.resolved.opacity,
+ blendMode: node.params.blendMode ?? "normal",
+ effectPassGroups: node.resolved.effectPasses,
+ mask: null,
+ });
+}
+
+function computeVisualTransform({
+ renderer,
+ resolved,
+ sourceWidth,
+ sourceHeight,
+}: {
+ renderer: CanvasRenderer;
+ resolved: ResolvedVisualSourceNodeState | ResolvedGraphicNodeState;
+ sourceWidth: number;
+ sourceHeight: number;
+}): QuadTransformDescriptor {
+ const containScale = Math.min(
+ renderer.width / sourceWidth,
+ renderer.height / sourceHeight,
+ );
+ const scaledWidth = sourceWidth * containScale * resolved.transform.scaleX;
+ const scaledHeight = sourceHeight * containScale * resolved.transform.scaleY;
+ const absWidth = Math.abs(scaledWidth);
+ const absHeight = Math.abs(scaledHeight);
+
+ return {
+ centerX: renderer.width / 2 + resolved.transform.position.x,
+ centerY: renderer.height / 2 + resolved.transform.position.y,
+ width: absWidth,
+ height: absHeight,
+ rotationDegrees: resolved.transform.rotate,
+ flipX: scaledWidth < 0,
+ flipY: scaledHeight < 0,
+ };
+}
+
+function fullCanvasTransform(renderer: CanvasRenderer): QuadTransformDescriptor {
+ return {
+ centerX: renderer.width / 2,
+ centerY: renderer.height / 2,
+ width: renderer.width,
+ height: renderer.height,
+ rotationDegrees: 0,
+ flipX: false,
+ flipY: false,
+ };
+}
+
+function buildMaskArtifacts({
+ node,
+ renderer,
+ path,
+ transform,
+ textures,
+}: {
+ node: VideoNode | ImageNode | StickerNode | GraphicNode;
+ renderer: CanvasRenderer;
+ path: string;
+ transform: QuadTransformDescriptor;
+ textures: Map<string, TextureUploadDescriptor>;
+}): {
+ mask: LayerMaskDescriptor | null;
+ strokeLayer: FrameItemDescriptor | null;
+} {
+ const mask = node.params.masks?.[0];
+ if (!mask) {
+ return { mask: null, strokeLayer: null };
+ }
+
+ const definition = masksRegistry.get(mask.type);
+ const elementMaskCanvas = createOffscreenCanvas({
+ width: Math.round(transform.width),
+ height: Math.round(transform.height),
+ });
+ const elementMaskCtx = elementMaskCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!elementMaskCtx) {
+ return { mask: null, strokeLayer: null };
+ }
+ elementMaskCtx.clearRect(0, 0, transform.width, transform.height);
+
+ let strokePath: Path2D | null = null;
+ let feather = mask.params.feather;
+ if (mask.params.feather > 0 && definition.renderer.renderMask) {
+ definition.renderer.renderMask({
+ resolvedParams: mask.params,
+ ctx: elementMaskCtx,
+ width: Math.round(transform.width),
+ height: Math.round(transform.height),
+ feather: mask.params.feather,
+ });
+ feather = 0;
+ strokePath = definition.renderer.buildStrokePath?.({
+ resolvedParams: mask.params,
+ width: transform.width,
+ height: transform.height,
+ }) ?? null;
+ } else {
+ const path2d = definition.renderer.buildPath({
+ resolvedParams: mask.params,
+ width: transform.width,
+ height: transform.height,
+ });
+ elementMaskCtx.fillStyle = "white";
+ elementMaskCtx.fill(path2d);
+ strokePath =
+ definition.renderer.buildStrokePath?.({
+ resolvedParams: mask.params,
+ width: transform.width,
+ height: transform.height,
+ }) ?? path2d;
+ }
+
+ const fullMaskCanvas = createOffscreenCanvas({
+ width: renderer.width,
+ height: renderer.height,
+ });
+ const fullMaskCtx = fullMaskCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!fullMaskCtx) {
+ return { mask: null, strokeLayer: null };
+ }
+ drawTransformedCanvas({
+ ctx: fullMaskCtx,
+ source: elementMaskCanvas,
+ transform,
+ });
+
+ const maskTextureId = `${path}:mask`;
+ textures.set(maskTextureId, {
+ id: maskTextureId,
+ source: fullMaskCanvas,
+ width: renderer.width,
+ height: renderer.height,
+ });
+
+ let strokeLayer: FrameItemDescriptor | null = null;
+ if (mask.params.strokeWidth > 0 && strokePath) {
+ const strokeCanvas = createOffscreenCanvas({
+ width: Math.round(transform.width),
+ height: Math.round(transform.height),
+ });
+ const strokeCtx = strokeCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (strokeCtx) {
+ strokeCtx.strokeStyle = mask.params.strokeColor;
+ strokeCtx.lineWidth = mask.params.strokeWidth;
+ strokeCtx.stroke(strokePath);
+
+ const fullStrokeCanvas = createOffscreenCanvas({
+ width: renderer.width,
+ height: renderer.height,
+ });
+ const fullStrokeCtx = fullStrokeCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (fullStrokeCtx) {
+ drawTransformedCanvas({
+ ctx: fullStrokeCtx,
+ source: strokeCanvas,
+ transform,
+ });
+ const strokeTextureId = `${path}:mask-stroke`;
+ textures.set(strokeTextureId, {
+ id: strokeTextureId,
+ source: fullStrokeCanvas,
+ width: renderer.width,
+ height: renderer.height,
+ });
+ strokeLayer = {
+ type: "layer",
+ textureId: strokeTextureId,
+ transform: fullCanvasTransform(renderer),
+ opacity: 1,
+ blendMode: "normal",
+ effectPassGroups: [],
+ mask: null,
+ };
+ }
+ }
+ }
+
+ return {
+ mask: {
+ textureId: maskTextureId,
+ feather,
+ inverted: mask.params.inverted,
+ },
+ strokeLayer,
+ };
+}
+
+function drawTransformedCanvas({
+ ctx,
+ source,
+ transform,
+}: {
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ source: CanvasImageSource;
+ transform: QuadTransformDescriptor;
+}) {
+ const x = transform.centerX - transform.width / 2;
+ const y = transform.centerY - transform.height / 2;
+ const flipX = transform.flipX ? -1 : 1;
+ const flipY = transform.flipY ? -1 : 1;
+ const requiresTransform =
+ transform.rotationDegrees !== 0 || flipX !== 1 || flipY !== 1;
+
+ ctx.save();
+ if (requiresTransform) {
+ ctx.translate(transform.centerX, transform.centerY);
+ ctx.rotate((transform.rotationDegrees * Math.PI) / 180);
+ ctx.scale(flipX, flipY);
+ ctx.translate(-transform.centerX, -transform.centerY);
+ }
+ ctx.drawImage(source, x, y, transform.width, transform.height);
+ ctx.restore();
+}
+