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

View File

@ -69,6 +69,7 @@ function RenderTreeController() {
duration,
canvasSize: { width, height },
background: activeProject.settings.background,
isPreview: true,
});
editor.renderer.setRenderTree({ renderTree });

View File

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

View File

@ -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,
});
}
}

View File

@ -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,
}),
}),
);
}