summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-23 10:03:15 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-23 10:03:15 +0100
commitbaa5f33407da81463196cde7639592d1fc984494 (patch)
tree502f963f8961245f8a7e85194dcd0cf348249ac4 /apps/web
parentdbfecbba59bb37aa73a133987230f6badecb080c (diff)
downloadopencut-baa5f33407da81463196cde7639592d1fc984494.tar.gz
fix preview perf issues
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx1
-rw-r--r--apps/web/src/services/renderer/nodes/image-node.ts80
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts53
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts6
4 files changed, 99 insertions, 41 deletions
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
index 90cb11a3..df60db01 100644
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -69,6 +69,7 @@ function RenderTreeController() {
duration,
canvasSize: { width, height },
background: activeProject.settings.background,
+ isPreview: true,
});
editor.renderer.setRenderTree({ renderTree });
diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts
index 49e84ebe..b3a0be74 100644
--- a/apps/web/src/services/renderer/nodes/image-node.ts
+++ b/apps/web/src/services/renderer/nodes/image-node.ts
@@ -3,26 +3,71 @@ 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;
});
+
+ const naturalWidth = image.naturalWidth;
+ const naturalHeight = image.naturalHeight;
+ const exceedsLimit =
+ maxSourceSize &&
+ (naturalWidth > maxSourceSize || naturalHeight > maxSourceSize);
+
+ 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 };
+ }
+ }
+
+ 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 }) {
@@ -32,20 +77,13 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
return;
}
- await this.readyPromise;
-
- if (!this.image) {
- 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,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index a2fb0170..acf638eb 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -6,29 +6,46 @@ 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 }) {
@@ -38,17 +55,13 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
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,
});
}
}
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index ee7f3d6c..530b2eb3 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -11,12 +11,15 @@ import type { TBackground, TCanvasSize } from "@/types/project";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { isMainTrack } from "@/lib/timeline";
+const PREVIEW_MAX_IMAGE_SIZE = 2048;
+
export type BuildSceneParams = {
canvasSize: TCanvasSize;
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
duration: number;
background: TBackground;
+ isPreview?: boolean;
};
export function buildScene(params: BuildSceneParams) {
@@ -81,6 +84,9 @@ export function buildScene(params: BuildSceneParams) {
transform: element.transform,
opacity: element.opacity,
blendMode: element.blendMode,
+ ...(params.isPreview && {
+ maxSourceSize: PREVIEW_MAX_IMAGE_SIZE,
+ }),
}),
);
}