mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
perf: improve rendering performance by ~20x
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { drawCssBackground } from "@/gradients";
|
||||
import { masksRegistry } from "@/masks";
|
||||
import { incrementCounter } from "@/diagnostics/render-perf";
|
||||
import type { AnyBaseNode } from "../nodes/base-node";
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { createOffscreenCanvas } from "../canvas-utils";
|
||||
@@ -21,16 +22,11 @@ import type {
|
||||
FrameItemDescriptor,
|
||||
LayerMaskDescriptor,
|
||||
QuadTransformDescriptor,
|
||||
TextureCanvasDrawFn,
|
||||
TextureUploadDescriptor,
|
||||
} from "./types";
|
||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
||||
|
||||
export type TextureUploadDescriptor = {
|
||||
id: string;
|
||||
source: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export async function buildFrameDescriptor({
|
||||
node,
|
||||
renderer,
|
||||
@@ -52,6 +48,9 @@ export async function buildFrameDescriptor({
|
||||
textures,
|
||||
});
|
||||
|
||||
incrementCounter({ name: "frameItems", by: items.length });
|
||||
incrementCounter({ name: "frameTextures", by: textures.size });
|
||||
|
||||
return {
|
||||
frame: {
|
||||
width: renderer.width,
|
||||
@@ -93,31 +92,21 @@ async function collectNode({
|
||||
|
||||
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);
|
||||
}
|
||||
const { width, height } = renderer;
|
||||
textures.set(textureId, {
|
||||
kind: "rendered",
|
||||
id: textureId,
|
||||
source: canvas,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
contentHash: `color:${node.params.color}:${width}x${height}`,
|
||||
width,
|
||||
height,
|
||||
draw: (ctx) => {
|
||||
if (/gradient\(/i.test(node.params.color)) {
|
||||
drawCssBackground({ ctx, width, height, css: node.params.color });
|
||||
} else {
|
||||
ctx.fillStyle = node.params.color;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
type: "layer",
|
||||
@@ -147,36 +136,35 @@ async function collectNode({
|
||||
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 { width, height } = renderer;
|
||||
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,
|
||||
);
|
||||
// Backdrop pixels come from a decoded video/image frame whose identity
|
||||
// already changes when it changes. Hashing the source reference is
|
||||
// enough to let us skip redraws on frozen frames.
|
||||
const contentHash = `blur:${identityKey(backdropSource.source)}:${backdropSource.width}x${backdropSource.height}:${width}x${height}`;
|
||||
textures.set(textureId, {
|
||||
kind: "rendered",
|
||||
id: textureId,
|
||||
source: backdropCanvas,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
contentHash,
|
||||
width,
|
||||
height,
|
||||
draw: (ctx) => {
|
||||
const coverScale = Math.max(
|
||||
width / backdropSource.width,
|
||||
height / backdropSource.height,
|
||||
);
|
||||
const scaledWidth = backdropSource.width * coverScale;
|
||||
const scaledHeight = backdropSource.height * coverScale;
|
||||
const offsetX = (width - scaledWidth) / 2;
|
||||
const offsetY = (height - scaledHeight) / 2;
|
||||
ctx.drawImage(
|
||||
backdropSource.source,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
);
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
type: "layer",
|
||||
@@ -253,6 +241,7 @@ async function collectVisualSourceNode({
|
||||
|
||||
const textureId = `${path}:source`;
|
||||
textures.set(textureId, {
|
||||
kind: "external",
|
||||
id: textureId,
|
||||
source,
|
||||
width: sourceWidth,
|
||||
@@ -305,28 +294,24 @@ function collectTextNode({
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const { width, height } = renderer;
|
||||
// Text output is fully determined by node.params + node.resolved. Both are
|
||||
// plain data we can stringify cheaply; the resolved measured layout is the
|
||||
// expensive part of text setup, so stringifying it here is orders of
|
||||
// magnitude cheaper than re-rasterizing when nothing changed.
|
||||
const contentHash = `text:${width}x${height}:${JSON.stringify({
|
||||
params: node.params,
|
||||
resolved: node.resolved,
|
||||
})}`;
|
||||
textures.set(textureId, {
|
||||
kind: "rendered",
|
||||
id: textureId,
|
||||
source: canvas,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
contentHash,
|
||||
width,
|
||||
height,
|
||||
draw: (ctx) => {
|
||||
renderTextToContext({ node, ctx });
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
type: "layer",
|
||||
@@ -411,20 +396,6 @@ function buildMaskArtifacts({
|
||||
return { mask: null, strokeLayer: null };
|
||||
}
|
||||
|
||||
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;
|
||||
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
|
||||
const shouldRenderMaskDirectly =
|
||||
@@ -432,81 +403,78 @@ function buildMaskArtifacts({
|
||||
(!definition.renderer.buildPath ||
|
||||
(mask.params.feather > 0 &&
|
||||
definition.renderer.renderMaskHandlesFeather));
|
||||
if (shouldRenderMaskDirectly && 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,
|
||||
});
|
||||
if (definition.renderer.renderMaskHandlesFeather) {
|
||||
feather = 0;
|
||||
}
|
||||
strokePath =
|
||||
definition.renderer.buildStrokePath?.({
|
||||
resolvedParams: mask.params,
|
||||
width: transform.width,
|
||||
height: transform.height,
|
||||
}) ?? null;
|
||||
} else {
|
||||
if (!definition.renderer.buildPath) {
|
||||
return { mask: null, strokeLayer: null };
|
||||
}
|
||||
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;
|
||||
if (
|
||||
shouldRenderMaskDirectly &&
|
||||
definition.renderer.renderMaskHandlesFeather
|
||||
) {
|
||||
feather = 0;
|
||||
}
|
||||
|
||||
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 || definition.renderer.renderStroke)
|
||||
) {
|
||||
const strokeCanvas = createOffscreenCanvas({
|
||||
const { width: canvasWidth, height: canvasHeight } = renderer;
|
||||
const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`;
|
||||
const drawMask: TextureCanvasDrawFn = (ctx) => {
|
||||
const elementMaskCanvas = createOffscreenCanvas({
|
||||
width: Math.round(transform.width),
|
||||
height: Math.round(transform.height),
|
||||
});
|
||||
const strokeCtx = strokeCanvas.getContext("2d") as
|
||||
const elementMaskCtx = elementMaskCanvas.getContext("2d") as
|
||||
| CanvasRenderingContext2D
|
||||
| OffscreenCanvasRenderingContext2D
|
||||
| null;
|
||||
if (strokeCtx) {
|
||||
if (!elementMaskCtx) return;
|
||||
|
||||
if (shouldRenderMaskDirectly && 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,
|
||||
});
|
||||
} else if (definition.renderer.buildPath) {
|
||||
const path2d = definition.renderer.buildPath({
|
||||
resolvedParams: mask.params,
|
||||
width: transform.width,
|
||||
height: transform.height,
|
||||
});
|
||||
elementMaskCtx.fillStyle = "white";
|
||||
elementMaskCtx.fill(path2d);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform });
|
||||
};
|
||||
textures.set(maskTextureId, {
|
||||
kind: "rendered",
|
||||
id: maskTextureId,
|
||||
contentHash: maskContentHash,
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
draw: drawMask,
|
||||
});
|
||||
|
||||
const hasStroke =
|
||||
mask.params.strokeWidth > 0 &&
|
||||
(definition.renderer.renderStroke ||
|
||||
definition.renderer.buildStrokePath ||
|
||||
definition.renderer.buildPath);
|
||||
let strokeLayer: FrameItemDescriptor | null = null;
|
||||
if (hasStroke) {
|
||||
const strokeTextureId = `${path}:mask-stroke`;
|
||||
const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`;
|
||||
const drawStroke: TextureCanvasDrawFn = (ctx) => {
|
||||
const strokeCanvas = createOffscreenCanvas({
|
||||
width: Math.round(transform.width),
|
||||
height: Math.round(transform.height),
|
||||
});
|
||||
const strokeCtx = strokeCanvas.getContext("2d") as
|
||||
| CanvasRenderingContext2D
|
||||
| OffscreenCanvasRenderingContext2D
|
||||
| null;
|
||||
if (!strokeCtx) return;
|
||||
|
||||
if (definition.renderer.renderStroke) {
|
||||
definition.renderer.renderStroke({
|
||||
resolvedParams: mask.params,
|
||||
@@ -514,44 +482,44 @@ function buildMaskArtifacts({
|
||||
width: transform.width,
|
||||
height: transform.height,
|
||||
});
|
||||
} else if (strokePath) {
|
||||
} else {
|
||||
const strokePath =
|
||||
definition.renderer.buildStrokePath?.({
|
||||
resolvedParams: mask.params,
|
||||
width: transform.width,
|
||||
height: transform.height,
|
||||
}) ??
|
||||
definition.renderer.buildPath?.({
|
||||
resolvedParams: mask.params,
|
||||
width: transform.width,
|
||||
height: transform.height,
|
||||
}) ??
|
||||
null;
|
||||
if (!strokePath) return;
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
drawTransformedCanvas({ ctx, source: strokeCanvas, transform });
|
||||
};
|
||||
textures.set(strokeTextureId, {
|
||||
kind: "rendered",
|
||||
id: strokeTextureId,
|
||||
contentHash: strokeContentHash,
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
draw: drawStroke,
|
||||
});
|
||||
strokeLayer = {
|
||||
type: "layer",
|
||||
textureId: strokeTextureId,
|
||||
transform: fullCanvasTransform(renderer),
|
||||
opacity: 1,
|
||||
blendMode: "normal",
|
||||
effectPassGroups: [],
|
||||
mask: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -590,3 +558,23 @@ function drawTransformedCanvas({
|
||||
ctx.drawImage(source, x, y, transform.width, transform.height);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function transformHash(transform: QuadTransformDescriptor): string {
|
||||
return `${transform.centerX}:${transform.centerY}:${transform.width}:${transform.height}:${transform.rotationDegrees}:${transform.flipX ? 1 : 0}:${transform.flipY ? 1 : 0}`;
|
||||
}
|
||||
|
||||
// Stable identity key for CanvasImageSource. Using a WeakMap → counter keeps
|
||||
// hash string length bounded and avoids holding sources alive.
|
||||
const identityKeys = new WeakMap<object, number>();
|
||||
let nextIdentity = 1;
|
||||
function identityKey(source: CanvasImageSource): string {
|
||||
if (typeof source === "object" && source !== null) {
|
||||
let key = identityKeys.get(source);
|
||||
if (key === undefined) {
|
||||
key = nextIdentity++;
|
||||
identityKeys.set(source, key);
|
||||
}
|
||||
return `@${key}`;
|
||||
}
|
||||
return "@?";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user