From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- apps/web/src/services/renderer/nodes/base-node.ts | 35 ++++++++ .../renderer/nodes/blur-background-node.ts | 75 +++++++++++++++++ apps/web/src/services/renderer/nodes/color-node.ts | 31 ++++++++ apps/web/src/services/renderer/nodes/image-node.ts | 93 ++++++++++++++++++++++ apps/web/src/services/renderer/nodes/root-node.ts | 11 +++ .../src/services/renderer/nodes/sticker-node.ts | 87 ++++++++++++++++++++ apps/web/src/services/renderer/nodes/text-node.ts | 74 +++++++++++++++++ apps/web/src/services/renderer/nodes/video-node.ts | 84 +++++++++++++++++++ 8 files changed, 490 insertions(+) create mode 100644 apps/web/src/services/renderer/nodes/base-node.ts create mode 100644 apps/web/src/services/renderer/nodes/blur-background-node.ts create mode 100644 apps/web/src/services/renderer/nodes/color-node.ts create mode 100644 apps/web/src/services/renderer/nodes/image-node.ts create mode 100644 apps/web/src/services/renderer/nodes/root-node.ts create mode 100644 apps/web/src/services/renderer/nodes/sticker-node.ts create mode 100644 apps/web/src/services/renderer/nodes/text-node.ts create mode 100644 apps/web/src/services/renderer/nodes/video-node.ts (limited to 'apps/web/src/services/renderer/nodes') diff --git a/apps/web/src/services/renderer/nodes/base-node.ts b/apps/web/src/services/renderer/nodes/base-node.ts new file mode 100644 index 00000000..55bd055a --- /dev/null +++ b/apps/web/src/services/renderer/nodes/base-node.ts @@ -0,0 +1,35 @@ +import type { CanvasRenderer } from "../canvas-renderer"; + +export type BaseNodeParams = object | undefined; + +export class BaseNode { + params: Params; + + constructor(params?: Params) { + this.params = params ?? ({} as Params); + } + + children: BaseNode[] = []; + + add(child: BaseNode) { + this.children.push(child); + return this; + } + + remove(child: BaseNode) { + this.children = this.children.filter((c) => c !== child); + return this; + } + + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise { + for (const child of this.children) { + await child.render({ renderer, time }); + } + } +} diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts new file mode 100644 index 00000000..504161d9 --- /dev/null +++ b/apps/web/src/services/renderer/nodes/blur-background-node.ts @@ -0,0 +1,75 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { BaseNode } from "./base-node"; + +export type BlurBackgroundNodeParams = { + blurIntensity: number; + contentNodes: BaseNode[]; +}; + +export class BlurBackgroundNode extends BaseNode { + private blurIntensity: number; + private contentNodes: BaseNode[]; + + constructor(params: BlurBackgroundNodeParams) { + super(params); + this.blurIntensity = params.blurIntensity; + this.contentNodes = params.contentNodes; + } + + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise { + let offscreen: OffscreenCanvas | HTMLCanvasElement; + let offscreenCtx: + | OffscreenCanvasRenderingContext2D + | CanvasRenderingContext2D; + + try { + offscreen = new OffscreenCanvas(renderer.width, renderer.height); + const ctx = offscreen.getContext("2d"); + if (!ctx) { + throw new Error("failed to get offscreen canvas context"); + } + offscreenCtx = ctx; + } catch { + offscreen = document.createElement("canvas"); + offscreen.width = renderer.width; + offscreen.height = renderer.height; + const ctx = offscreen.getContext("2d"); + if (!ctx) { + throw new Error("failed to get canvas context"); + } + offscreenCtx = ctx; + } + + const originalContext = renderer.context; + renderer.context = offscreenCtx; + + for (const node of this.contentNodes) { + await node.render({ renderer, time }); + } + + renderer.context = originalContext; + + const zoomScale = 1.4; + const scaledWidth = renderer.width * zoomScale; + const scaledHeight = renderer.height * zoomScale; + const offsetX = (renderer.width - scaledWidth) / 2; + const offsetY = (renderer.height - scaledHeight) / 2; + + renderer.context.save(); + renderer.context.filter = `blur(${this.blurIntensity}px)`; + renderer.context.drawImage( + offscreen as CanvasImageSource, + offsetX, + offsetY, + scaledWidth, + scaledHeight, + ); + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/nodes/color-node.ts b/apps/web/src/services/renderer/nodes/color-node.ts new file mode 100644 index 00000000..d3afbf9b --- /dev/null +++ b/apps/web/src/services/renderer/nodes/color-node.ts @@ -0,0 +1,31 @@ +import { drawCssBackground } from "@/lib/gradients"; +import type { CanvasRenderer } from "../canvas-renderer"; +import { BaseNode } from "./base-node"; + +export type ColorNodeParams = { + color: string; +}; + +export class ColorNode extends BaseNode { + private color: string; + + constructor(params: ColorNodeParams) { + super(params); + this.color = params.color; + } + + async render({ renderer }: { renderer: CanvasRenderer }) { + if (/gradient\(/i.test(this.color)) { + drawCssBackground({ + ctx: renderer.context, + width: renderer.width, + height: renderer.height, + css: this.color, + }); + return; + } + + renderer.context.fillStyle = this.color; + renderer.context.fillRect(0, 0, renderer.width, renderer.height); + } +} diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts new file mode 100644 index 00000000..2a684d5f --- /dev/null +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -0,0 +1,93 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { BaseNode } from "./base-node"; +import type { BaseMediaNodeParams } from "./video-node"; + +const IMAGE_EPSILON = 1 / 1000; + +export type ImageNodeParams = BaseMediaNodeParams; + +export class ImageNode extends BaseNode { + private image?: HTMLImageElement; + private readyPromise: Promise; + + constructor(params: ImageNodeParams) { + super(params); + this.readyPromise = this.load(); + } + + private async load() { + const image = new Image(); + this.image = image; + const url = URL.createObjectURL(this.params.file); + + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("Image load failed")); + image.src = url; + }); + + URL.revokeObjectURL(url); + } + + 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 }); + + if (!this.isInRange(time)) { + return; + } + + await this.readyPromise; + + if (!this.image) { + return; + } + + 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( + 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(); + } +} diff --git a/apps/web/src/services/renderer/nodes/root-node.ts b/apps/web/src/services/renderer/nodes/root-node.ts new file mode 100644 index 00000000..336018e2 --- /dev/null +++ b/apps/web/src/services/renderer/nodes/root-node.ts @@ -0,0 +1,11 @@ +import { BaseNode } from "./base-node"; + +export type RootNodeParams = { + duration: number; +}; + +export class RootNode extends BaseNode { + get duration() { + return this.params.duration ?? 0; + } +} diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts new file mode 100644 index 00000000..5af13a6f --- /dev/null +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -0,0 +1,87 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { BaseNode, type BaseNodeParams } from "./base-node"; +import type { Transform } from "@/types/timeline"; + +const STICKER_EPSILON = 1 / 1000; + +export type StickerNodeParams = BaseNodeParams & { + iconName: string; + duration: number; + timeOffset: number; + trimStart: number; + trimEnd: number; + transform: Transform; + opacity: number; + color?: string; +}; + +export class StickerNode extends BaseNode { + private image?: HTMLImageElement; + private readyPromise: Promise; + + constructor(params: StickerNodeParams) { + super(params); + this.readyPromise = this.load(); + } + + private async load() { + const image = new Image(); + this.image = image; + const color = this.params.color + ? `&color=${encodeURIComponent(this.params.color)}` + : ""; + const url = `https://api.iconify.design/${this.params.iconName}.svg?width=200&height=200${color}`; + + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => + reject(new Error(`Failed to load sticker: ${this.params.iconName}`)); + image.src = url; + }); + } + + 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 }); + + if (!this.isInRange(time)) { + return; + } + + await this.readyPromise; + + if (!this.image) { + 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(); + } +} diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts new file mode 100644 index 00000000..2cbf3d2a --- /dev/null +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -0,0 +1,74 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { BaseNode } from "./base-node"; +import type { TextElement } from "@/types/timeline"; + +export type TextNodeParams = TextElement & { + canvasCenter: { x: number; y: number }; + textBaseline?: CanvasTextBaseline; +}; + +export class TextNode extends BaseNode { + isInRange({ time }: { time: number }) { + return ( + time >= this.params.startTime && + time < this.params.startTime + this.params.duration + ); + } + + async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { + if (!this.isInRange({ time })) { + return; + } + + renderer.context.save(); + + const x = this.params.transform.position.x + this.params.canvasCenter.x; + const y = this.params.transform.position.y + this.params.canvasCenter.y; + + renderer.context.translate(x, y); + if (this.params.transform.rotate) { + renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180); + } + + const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal"; + renderer.context.font = `${fontStyle} ${fontWeight} ${this.params.fontSize}px ${this.params.fontFamily}`; + renderer.context.textAlign = this.params.textAlign; + renderer.context.textBaseline = this.params.textBaseline || "middle"; + renderer.context.fillStyle = this.params.color; + + const prevAlpha = renderer.context.globalAlpha; + renderer.context.globalAlpha = this.params.opacity; + + if (this.params.backgroundColor) { + const metrics = renderer.context.measureText(this.params.content); + const ascent = + metrics.actualBoundingBoxAscent ?? this.params.fontSize * 0.8; + const descent = + metrics.actualBoundingBoxDescent ?? this.params.fontSize * 0.2; + const textW = metrics.width; + const textH = ascent + descent; + const padX = 8; + const padY = 4; + + renderer.context.fillStyle = this.params.backgroundColor; + let bgLeft = -textW / 2; + if (renderer.context.textAlign === "left") bgLeft = 0; + if (renderer.context.textAlign === "right") bgLeft = -textW; + + renderer.context.fillRect( + bgLeft - padX, + -textH / 2 - padY, + textW + padX * 2, + textH + padY * 2, + ); + + renderer.context.fillStyle = this.params.color; + } + + renderer.context.fillText(this.params.content, 0, 0); + + renderer.context.globalAlpha = prevAlpha; + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts new file mode 100644 index 00000000..01394b3d --- /dev/null +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -0,0 +1,84 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { BaseNode } from "./base-node"; +import { videoCache } from "@/services/media/video-cache"; + +const VIDEO_EPSILON = 1 / 1000; + +export interface BaseMediaNodeParams { + file: File; + duration: number; + timeOffset: number; + trimStart: number; + trimEnd: number; + x?: number; + y?: number; + width?: number; + height?: number; + opacity?: number; +} + +export interface VideoNodeParams extends BaseMediaNodeParams { + mediaId: string; +} + +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 + ); + } + + async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { + await super.render({ renderer, time }); + + if (!this.isInRange(time)) { + return; + } + + const videoTime = this.getVideoTime(time); + const frame = await videoCache.getFrameAt({ + mediaId: this.params.mediaId, + file: this.params.file, + time: videoTime, + }); + + 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(); + } + } +} -- cgit v1.3.1