diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-02-23 10:03:15 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-02-23 10:03:15 +0100 |
| commit | baa5f33407da81463196cde7639592d1fc984494 (patch) | |
| tree | 502f963f8961245f8a7e85194dcd0cf348249ac4 | |
| parent | dbfecbba59bb37aa73a133987230f6badecb080c (diff) | |
| download | opencut-baa5f33407da81463196cde7639592d1fc984494.tar.gz | |
fix preview perf issues
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, + }), }), ); } |
