OpenCut/apps/web/src/services/renderer/compositor/frame-descriptor.ts

560 lines
13 KiB
TypeScript

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();
}