summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer/nodes
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services/renderer/nodes')
-rw-r--r--apps/web/src/services/renderer/nodes/base-node.ts35
-rw-r--r--apps/web/src/services/renderer/nodes/blur-background-node.ts75
-rw-r--r--apps/web/src/services/renderer/nodes/color-node.ts31
-rw-r--r--apps/web/src/services/renderer/nodes/image-node.ts93
-rw-r--r--apps/web/src/services/renderer/nodes/root-node.ts11
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts87
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts74
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts84
8 files changed, 490 insertions, 0 deletions
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 extends BaseNodeParams = BaseNodeParams> {
+ 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<void> {
+ 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<BlurBackgroundNodeParams> {
+ 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<void> {
+ 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<ColorNodeParams> {
+ 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<ImageNodeParams> {
+ private image?: HTMLImageElement;
+ private readyPromise: Promise<void>;
+
+ 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<void>((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<RootNodeParams> {
+ 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<StickerNodeParams> {
+ private image?: HTMLImageElement;
+ private readyPromise: Promise<void>;
+
+ 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<void>((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<TextNodeParams> {
+ 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<VideoNodeParams> {
+ 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();
+ }
+ }
+}