fix preview perf issues

This commit is contained in:
Maze Winther
2026-02-23 10:03:15 +01:00
parent dbfecbba59
commit baa5f33407
4 changed files with 99 additions and 41 deletions
@@ -69,6 +69,7 @@ function RenderTreeController() {
duration, duration,
canvasSize: { width, height }, canvasSize: { width, height },
background: activeProject.settings.background, background: activeProject.settings.background,
isPreview: true,
}); });
editor.renderer.setRenderTree({ renderTree }); editor.renderer.setRenderTree({ renderTree });
@@ -3,26 +3,71 @@ import { VisualNode, type VisualNodeParams } from "./visual-node";
export interface ImageNodeParams extends VisualNodeParams { export interface ImageNodeParams extends VisualNodeParams {
url: string; url: string;
maxSourceSize?: number;
} }
export class ImageNode extends VisualNode<ImageNodeParams> { interface CachedImageSource {
private image?: HTMLImageElement; source: HTMLImageElement | OffscreenCanvas;
private readyPromise: Promise<void>; width: number;
height: number;
}
constructor(params: ImageNodeParams) { const imageSourceCache = new Map<string, Promise<CachedImageSource>>();
super(params);
this.readyPromise = this.load();
}
private async load() { function loadImageSource(
url: string,
maxSourceSize?: number,
): Promise<CachedImageSource> {
const cacheKey = `${url}::${maxSourceSize ?? "full"}`;
const cached = imageSourceCache.get(cacheKey);
if (cached) return cached;
const promise = (async (): Promise<CachedImageSource> => {
const image = new Image(); const image = new Image();
this.image = image;
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
image.onload = () => resolve(); image.onload = () => resolve();
image.onerror = () => reject(new Error("Image load failed")); 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 }) { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
@@ -32,20 +77,13 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
return; return;
} }
await this.readyPromise; const { source, width, height } = await this.cachedSource;
if (!this.image) {
return;
}
const mediaW = this.image.naturalWidth || renderer.width;
const mediaH = this.image.naturalHeight || renderer.height;
this.renderVisual({ this.renderVisual({
renderer, renderer,
source: this.image, source,
sourceWidth: mediaW, sourceWidth: width || renderer.width,
sourceHeight: mediaH, sourceHeight: height || renderer.height,
}); });
} }
} }
@@ -6,29 +6,46 @@ export interface StickerNodeParams extends VisualNodeParams {
stickerId: string; stickerId: string;
} }
export class StickerNode extends VisualNode<StickerNodeParams> { interface CachedStickerSource {
private image?: HTMLImageElement; source: HTMLImageElement;
private readyPromise: Promise<void>; width: number;
height: number;
}
constructor(params: StickerNodeParams) { const stickerSourceCache = new Map<string, Promise<CachedStickerSource>>();
super(params);
this.readyPromise = this.load();
}
private async load() { function loadStickerSource(stickerId: string): Promise<CachedStickerSource> {
const image = new Image(); const cached = stickerSourceCache.get(stickerId);
this.image = image; if (cached) return cached;
const promise = (async (): Promise<CachedStickerSource> => {
const url = resolveStickerId({ const url = resolveStickerId({
stickerId: this.params.stickerId, stickerId,
options: { width: 200, height: 200 }, options: { width: 200, height: 200 },
}); });
const image = new Image();
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
image.onload = () => resolve(); image.onload = () => resolve();
image.onerror = () => image.onerror = () =>
reject(new Error(`Failed to load sticker: ${this.params.stickerId}`)); reject(new Error(`Failed to load sticker: ${stickerId}`));
image.src = url; 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 }) { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
@@ -38,17 +55,13 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
return; return;
} }
await this.readyPromise; const { source, width, height } = await this.cachedSource;
if (!this.image) {
return;
}
this.renderVisual({ this.renderVisual({
renderer, renderer,
source: this.image, source,
sourceWidth: 200, sourceWidth: width,
sourceHeight: 200, sourceHeight: height,
}); });
} }
} }
@@ -11,12 +11,15 @@ import type { TBackground, TCanvasSize } from "@/types/project";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { isMainTrack } from "@/lib/timeline"; import { isMainTrack } from "@/lib/timeline";
const PREVIEW_MAX_IMAGE_SIZE = 2048;
export type BuildSceneParams = { export type BuildSceneParams = {
canvasSize: TCanvasSize; canvasSize: TCanvasSize;
tracks: TimelineTrack[]; tracks: TimelineTrack[];
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
duration: number; duration: number;
background: TBackground; background: TBackground;
isPreview?: boolean;
}; };
export function buildScene(params: BuildSceneParams) { export function buildScene(params: BuildSceneParams) {
@@ -81,6 +84,9 @@ export function buildScene(params: BuildSceneParams) {
transform: element.transform, transform: element.transform,
opacity: element.opacity, opacity: element.opacity,
blendMode: element.blendMode, blendMode: element.blendMode,
...(params.isPreview && {
maxSourceSize: PREVIEW_MAX_IMAGE_SIZE,
}),
}), }),
); );
} }