From e50a817b97b87baa0cd8fdde0fdb5d607143c1c3 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 8 Feb 2026 22:21:17 +0100 Subject: add transform + few fixes feat: transform feat: batch command fix: slider undo fix: timeline zoom perf refactor: make updateElement support multiple --- apps/web/src/services/renderer/nodes/image-node.ts | 71 ++++------------------ .../src/services/renderer/nodes/sticker-node.ts | 53 +++------------- apps/web/src/services/renderer/nodes/video-node.ts | 67 +++----------------- .../web/src/services/renderer/nodes/visual-node.ts | 67 ++++++++++++++++++++ apps/web/src/services/renderer/scene-builder.ts | 4 ++ 5 files changed, 102 insertions(+), 160 deletions(-) create mode 100644 apps/web/src/services/renderer/nodes/visual-node.ts (limited to 'apps/web/src/services') diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index 5405b60b..49e84ebe 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -1,22 +1,11 @@ import type { CanvasRenderer } from "../canvas-renderer"; -import { BaseNode } from "./base-node"; +import { VisualNode, type VisualNodeParams } from "./visual-node"; -const IMAGE_EPSILON = 1 / 1000; - -export interface ImageNodeParams { +export interface ImageNodeParams extends VisualNodeParams { url: string; - duration: number; - timeOffset: number; - trimStart: number; - trimEnd: number; - x?: number; - y?: number; - width?: number; - height?: number; - opacity?: number; } -export class ImageNode extends BaseNode { +export class ImageNode extends VisualNode { private image?: HTMLImageElement; private readyPromise: Promise; @@ -36,18 +25,6 @@ export class ImageNode extends BaseNode { }); } - private getImageTime(time: number) { - return time - this.params.timeOffset + this.params.trimStart; - } - - private isInRange(time: number) { - const imageTime = this.getImageTime(time); - return ( - imageTime >= this.params.trimStart - IMAGE_EPSILON && - imageTime < this.params.trimStart + this.params.duration - ); - } - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); @@ -61,40 +38,14 @@ export class ImageNode extends BaseNode { return; } - renderer.context.save(); + const mediaW = this.image.naturalWidth || renderer.width; + const mediaH = this.image.naturalHeight || renderer.height; - if (this.params.opacity !== undefined) { - renderer.context.globalAlpha = this.params.opacity; - } - - if ( - this.params.x !== undefined && - this.params.y !== undefined && - this.params.width !== undefined && - this.params.height !== undefined - ) { - renderer.context.drawImage( - this.image, - this.params.x, - this.params.y, - this.params.width, - this.params.height, - ); - } else { - const mediaW = this.image.naturalWidth || renderer.width; - const mediaH = this.image.naturalHeight || renderer.height; - const containScale = Math.min( - renderer.width / mediaW, - renderer.height / mediaH, - ); - const drawW = mediaW * containScale; - const drawH = mediaH * containScale; - const drawX = (renderer.width - drawW) / 2; - const drawY = (renderer.height - drawH) / 2; - - renderer.context.drawImage(this.image, drawX, drawY, drawW, drawH); - } - - renderer.context.restore(); + this.renderVisual({ + renderer, + source: this.image, + sourceWidth: mediaW, + sourceHeight: mediaH, + }); } } diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts index 5af13a6f..9842320e 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -1,21 +1,12 @@ import type { CanvasRenderer } from "../canvas-renderer"; -import { BaseNode, type BaseNodeParams } from "./base-node"; -import type { Transform } from "@/types/timeline"; +import { VisualNode, type VisualNodeParams } from "./visual-node"; -const STICKER_EPSILON = 1 / 1000; - -export type StickerNodeParams = BaseNodeParams & { +export interface StickerNodeParams extends VisualNodeParams { iconName: string; - duration: number; - timeOffset: number; - trimStart: number; - trimEnd: number; - transform: Transform; - opacity: number; color?: string; -}; +} -export class StickerNode extends BaseNode { +export class StickerNode extends VisualNode { private image?: HTMLImageElement; private readyPromise: Promise; @@ -40,18 +31,6 @@ export class StickerNode extends BaseNode { }); } - private getStickerTime(time: number) { - return time - this.params.timeOffset + this.params.trimStart; - } - - private isInRange(time: number) { - const stickerTime = this.getStickerTime(time); - return ( - stickerTime >= this.params.trimStart - STICKER_EPSILON && - stickerTime < this.params.trimStart + this.params.duration - ); - } - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); @@ -65,23 +44,11 @@ export class StickerNode extends BaseNode { return; } - const { transform, opacity } = this.params; - const size = 200 * transform.scale; - const x = renderer.width / 2 + transform.position.x - size / 2; - const y = renderer.height / 2 + transform.position.y - size / 2; - - renderer.context.save(); - renderer.context.globalAlpha = opacity; - - if (transform.rotate !== 0) { - const centerX = x + size / 2; - const centerY = y + size / 2; - renderer.context.translate(centerX, centerY); - renderer.context.rotate((transform.rotate * Math.PI) / 180); - renderer.context.translate(-centerX, -centerY); - } - - renderer.context.drawImage(this.image, x, y, size, size); - renderer.context.restore(); + this.renderVisual({ + renderer, + source: this.image, + sourceWidth: 200, + sourceHeight: 200, + }); } } diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts index 841eb459..8e2ddccd 100644 --- a/apps/web/src/services/renderer/nodes/video-node.ts +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -1,37 +1,14 @@ import type { CanvasRenderer } from "../canvas-renderer"; -import { BaseNode } from "./base-node"; +import { VisualNode, type VisualNodeParams } from "./visual-node"; import { videoCache } from "@/services/video-cache/service"; -const VIDEO_EPSILON = 1 / 1000; - -export interface VideoNodeParams { +export interface VideoNodeParams extends VisualNodeParams { url: string; file: File; mediaId: string; - duration: number; - timeOffset: number; - trimStart: number; - trimEnd: number; - x?: number; - y?: number; - width?: number; - height?: number; - opacity?: number; } -export class VideoNode extends BaseNode { - private getVideoTime(time: number) { - return time - this.params.timeOffset + this.params.trimStart; - } - - private isInRange(time: number) { - const videoTime = this.getVideoTime(time); - return ( - videoTime >= this.params.trimStart - VIDEO_EPSILON && - videoTime < this.params.trimStart + this.params.duration - ); - } - +export class VideoNode extends VisualNode { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); @@ -39,7 +16,7 @@ export class VideoNode extends BaseNode { return; } - const videoTime = this.getVideoTime(time); + const videoTime = this.getLocalTime(time); const frame = await videoCache.getFrameAt({ mediaId: this.params.mediaId, file: this.params.file, @@ -47,36 +24,12 @@ export class VideoNode extends BaseNode { }); if (frame) { - renderer.context.save(); - - if (this.params.opacity !== undefined) { - renderer.context.globalAlpha = this.params.opacity; - } - - if ( - this.params.x !== undefined && - this.params.y !== undefined && - this.params.width !== undefined && - this.params.height !== undefined - ) { - renderer.context.drawImage( - frame.canvas, - this.params.x, - this.params.y, - this.params.width, - this.params.height, - ); - } else { - renderer.context.drawImage( - frame.canvas, - 0, - 0, - renderer.width, - renderer.height, - ); - } - - renderer.context.restore(); + this.renderVisual({ + renderer, + source: frame.canvas, + sourceWidth: frame.canvas.width, + sourceHeight: frame.canvas.height, + }); } } } diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts new file mode 100644 index 00000000..9c6c88ca --- /dev/null +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -0,0 +1,67 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { BaseNode } from "./base-node"; +import type { Transform } from "@/types/timeline"; + +const VISUAL_EPSILON = 1 / 1000; + +export interface VisualNodeParams { + duration: number; + timeOffset: number; + trimStart: number; + trimEnd: number; + transform: Transform; + opacity: number; +} + +export abstract class VisualNode< + Params extends VisualNodeParams = VisualNodeParams, +> extends BaseNode { + protected getLocalTime(time: number): number { + return time - this.params.timeOffset + this.params.trimStart; + } + + protected isInRange(time: number): boolean { + const localTime = this.getLocalTime(time); + return ( + localTime >= this.params.trimStart - VISUAL_EPSILON && + localTime < this.params.trimStart + this.params.duration + ); + } + + protected renderVisual({ + renderer, + source, + sourceWidth, + sourceHeight, + }: { + renderer: CanvasRenderer; + source: CanvasImageSource; + sourceWidth: number; + sourceHeight: number; + }): void { + renderer.context.save(); + + const { transform, opacity } = this.params; + const containScale = Math.min( + renderer.width / sourceWidth, + renderer.height / sourceHeight, + ); + const scaledWidth = sourceWidth * containScale * transform.scale; + const scaledHeight = sourceHeight * containScale * transform.scale; + const x = renderer.width / 2 + transform.position.x - scaledWidth / 2; + const y = renderer.height / 2 + transform.position.y - scaledHeight / 2; + + renderer.context.globalAlpha = opacity; + + if (transform.rotate !== 0) { + const centerX = x + scaledWidth / 2; + const centerY = y + scaledHeight / 2; + renderer.context.translate(centerX, centerY); + renderer.context.rotate((transform.rotate * Math.PI) / 180); + renderer.context.translate(-centerX, -centerY); + } + + renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 5d33524e..d1b8217e 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -64,6 +64,8 @@ export function buildScene(params: BuildSceneParams) { timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, + transform: element.transform, + opacity: element.opacity, }), ); } @@ -75,6 +77,8 @@ export function buildScene(params: BuildSceneParams) { timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, + transform: element.transform, + opacity: element.opacity, }), ); } -- cgit v1.3.1