diff options
Diffstat (limited to 'apps/web/src/services')
20 files changed, 1354 insertions, 270 deletions
diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts new file mode 100644 index 00000000..093168e6 --- /dev/null +++ b/apps/web/src/services/renderer/canvas-utils.ts @@ -0,0 +1,16 @@ +export function createOffscreenCanvas({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement { + try { + return new OffscreenCanvas(width, height); + } catch { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; + } +} diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts new file mode 100644 index 00000000..2cebec5d --- /dev/null +++ b/apps/web/src/services/renderer/effect-preview.ts @@ -0,0 +1,194 @@ +import { createOffscreenCanvas } from "./canvas-utils"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { applyMultiPassEffect } from "./webgl-utils"; +import type { EffectPassData } from "./webgl-utils"; + +const PREVIEW_SIZE = 160; +const PREVIEW_IMAGE_PATH = "/effects/preview.jpg"; + +let previewGl: WebGLRenderingContext | null = null; +let previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; +let testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; +let previewImageElement: HTMLImageElement | null = null; +const programCache = new Map<string, WebGLProgram>(); +const onReadyCallbacks = new Set<() => void>(); + +export function onPreviewImageReady({ + callback, +}: { + callback: () => void; +}): () => void { + onReadyCallbacks.add(callback); + return () => onReadyCallbacks.delete(callback); +} + +function loadPreviewImage(): void { + if (typeof window === "undefined") return; + const image = new Image(); + image.onload = () => { + testSourceCanvas = null; + for (const callback of onReadyCallbacks) { + callback(); + } + }; + image.src = PREVIEW_IMAGE_PATH; + previewImageElement = image; +} + +loadPreviewImage(); + +function buildDefaultParams({ + effectType, +}: { + effectType: string; +}): EffectParamValues { + const definition = getEffect({ effectType }); + const params: EffectParamValues = {}; + for (const paramDef of definition.params) { + params[paramDef.key] = paramDef.default; + } + return params; +} + +function createTestSource({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement | null { + const isImageReady = + previewImageElement?.complete && + (previewImageElement.naturalWidth ?? 0) > 0; + if (!isImageReady || !previewImageElement) { + return null; + } + + const canvas = createOffscreenCanvas({ width, height }); + const ctx = canvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!ctx) { + throw new Error("failed to get 2d context for test source"); + } + ctx.drawImage(previewImageElement, 0, 0, width, height); + return canvas; +} + +function getOrCreatePreviewContext({ + width, + height, +}: { + width: number; + height: number; +}): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } { + if (!previewCanvas || !previewGl) { + previewCanvas = createOffscreenCanvas({ width, height }); + previewGl = previewCanvas.getContext("webgl", { + premultipliedAlpha: false, + }) as WebGLRenderingContext | null; + if (!previewGl) { + throw new Error("WebGL not supported"); + } + } + if (previewCanvas.width !== width || previewCanvas.height !== height) { + previewCanvas.width = width; + previewCanvas.height = height; + } + return { canvas: previewCanvas, gl: previewGl }; +} + +function getTestSource({ + width, + height, +}: { + width: number; + height: number; +}): CanvasImageSource | null { + if ( + !testSourceCanvas || + testSourceCanvas.width !== width || + testSourceCanvas.height !== height + ) { + testSourceCanvas = createTestSource({ width, height }); + } + return testSourceCanvas; +} + +function applyWebGlEffect({ + source, + width, + height, + passes, +}: { + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; +}): OffscreenCanvas | HTMLCanvasElement { + const { canvas: glCanvas, gl } = getOrCreatePreviewContext({ width, height }); + + applyMultiPassEffect({ context: gl, source, width, height, passes, programCache }); + + const outputCanvas = createOffscreenCanvas({ width, height }); + const outputCtx = outputCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (outputCtx) { + outputCtx.drawImage(glCanvas, 0, 0, width, height); + } + return outputCanvas; +} + +export function renderPreview({ + effectType, + params, + targetCanvas, +}: { + effectType: string; + params: EffectParamValues; + targetCanvas: HTMLCanvasElement; +}): void { + const size = PREVIEW_SIZE; + const source = getTestSource({ width: size, height: size }); + if (!source) return; + + const definition = getEffect({ effectType }); + const resolvedParams = + Object.keys(params).length > 0 + ? params + : buildDefaultParams({ effectType }); + + const passes = definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: resolvedParams, + width: size, + height: size, + }), + })); + const result = applyWebGlEffect({ + source, + width: size, + height: size, + passes, + }); + + const targetCtx = targetCanvas.getContext( + "2d", + ) as CanvasRenderingContext2D | null; + if (targetCtx) { + targetCanvas.width = size; + targetCanvas.height = size; + targetCtx.drawImage(result, 0, 0, size, size); + } +} + +export const effectPreviewService = { + renderPreview, + onPreviewImageReady, + PREVIEW_SIZE, +}; diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts deleted file mode 100644 index 504161d9..00000000 --- a/apps/web/src/services/renderer/nodes/blur-background-node.ts +++ /dev/null @@ -1,75 +0,0 @@ -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/composite-effect-node.ts b/apps/web/src/services/renderer/nodes/composite-effect-node.ts new file mode 100644 index 00000000..3fbfffc1 --- /dev/null +++ b/apps/web/src/services/renderer/nodes/composite-effect-node.ts @@ -0,0 +1,77 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { createOffscreenCanvas } from "../canvas-utils"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { BaseNode } from "./base-node"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; + +export type CompositeEffectNodeParams = { + contentNodes: BaseNode[]; + effectType: string; + effectParams: EffectParamValues; + scale: number; +}; + +export class CompositeEffectNode extends BaseNode<CompositeEffectNodeParams> { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise<void> { + const offscreen = createOffscreenCanvas({ + width: renderer.width, + height: renderer.height, + }); + const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null; + if (!offscreenCtx) { + throw new Error("failed to get offscreen canvas context"); + } + + const originalContext = renderer.context; + renderer.context = offscreenCtx; + + for (const node of this.params.contentNodes) { + await node.render({ renderer, time }); + } + + renderer.context = originalContext; + + const effectDefinition = getEffect({ effectType: this.params.effectType }); + const scale = this.params.scale; + const scaledWidth = renderer.width * scale; + const scaledHeight = renderer.height * scale; + const offsetX = (renderer.width - scaledWidth) / 2; + const offsetY = (renderer.height - scaledHeight) / 2; + + const passes = effectDefinition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: this.params.effectParams, + width: renderer.width, + height: renderer.height, + }), + })); + const effectResult = webglEffectRenderer.applyEffect({ + source: offscreen as CanvasImageSource, + width: renderer.width, + height: renderer.height, + passes, + }); + + renderer.context.save(); + renderer.context.drawImage( + effectResult, + 0, + 0, + renderer.width, + renderer.height, + offsetX, + offsetY, + scaledWidth, + scaledHeight, + ); + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts new file mode 100644 index 00000000..3d84be2b --- /dev/null +++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts @@ -0,0 +1,82 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { BaseNode } from "./base-node"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; + +const TIME_EPSILON = 1e-6; + +export type EffectLayerNodeParams = { + effectType: string; + effectParams: EffectParamValues; + timeOffset: number; + duration: number; +}; + +function isInRange({ + time, + timeOffset, + duration, +}: { + time: number; + timeOffset: number; + duration: number; +}): boolean { + return ( + time >= timeOffset - TIME_EPSILON && + time < timeOffset + duration + TIME_EPSILON + ); +} + +// snapshots whatever is currently on the canvas, applies the effect, draws it back +export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise<void> { + if ( + !isInRange({ + time, + timeOffset: this.params.timeOffset, + duration: this.params.duration, + }) + ) { + return; + } + + const source = renderer.context.canvas as CanvasImageSource; + + const effectDefinition = getEffect({ + effectType: this.params.effectType, + }); + + const passes = effectDefinition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: this.params.effectParams, + width: renderer.width, + height: renderer.height, + }), + })); + const effectResult = webglEffectRenderer.applyEffect({ + source, + width: renderer.width, + height: renderer.height, + passes, + }); + + renderer.context.save(); + renderer.context.clearRect(0, 0, renderer.width, renderer.height); + renderer.context.drawImage( + effectResult, + 0, + 0, + renderer.width, + renderer.height, + ); + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index 49e84ebe..6720a5e5 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -3,49 +3,88 @@ 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>>(); + +function loadImageSource( + url: string, + maxSourceSize?: number, +): Promise<CachedImageSource> { + const cacheKey = `${url}::${maxSourceSize ?? "full"}`; - private async load() { + 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; }); - } - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { - await super.render({ renderer, time }); + const naturalWidth = image.naturalWidth; + const naturalHeight = image.naturalHeight; + const exceedsLimit = + maxSourceSize && + (naturalWidth > maxSourceSize || naturalHeight > maxSourceSize); - if (!this.isInRange(time)) { - return; + 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 }; + } } - await this.readyPromise; + 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 }) { + await super.render({ renderer, time }); - if (!this.image) { + if (!this.isInRange({ time })) { 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, + timelineTime: time, }); } } diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts index a2fb0170..da271685 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -6,49 +6,63 @@ 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 }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { 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, + timelineTime: time, }); } } diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index b7e36d64..0ddb4eae 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -2,9 +2,21 @@ import type { CanvasRenderer } from "../canvas-renderer"; import { BaseNode } from "./base-node"; import type { TextElement } from "@/types/timeline"; import { + DEFAULT_TEXT_ELEMENT, DEFAULT_LINE_HEIGHT, FONT_SIZE_SCALE_REFERENCE, } from "@/constants/text-constants"; +import { + getMetricAscent, + getMetricDescent, + getTextBackgroundRect, + measureTextBlock, +} from "@/lib/text/layout"; +import { + getElementLocalTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "@/lib/animation"; function scaleFontSize({ fontSize, @@ -20,65 +32,6 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string { return `"${fontFamily.replace(/"/g, '\\"')}"`; } -function getMetricAscent({ - metrics, - fallback, -}: { - metrics: TextMetrics; - fallback: number; -}): number { - return metrics.actualBoundingBoxAscent ?? fallback * 0.8; -} - -function getMetricDescent({ - metrics, - fallback, -}: { - metrics: TextMetrics; - fallback: number; -}): number { - return metrics.actualBoundingBoxDescent ?? fallback * 0.2; -} - -interface TextBlockMeasurement { - visualCenterOffset: number; - height: number; - maxWidth: number; -} - -function measureTextBlock({ - lineMetrics, - lineHeightPx, - fallbackFontSize, -}: { - lineMetrics: TextMetrics[]; - lineHeightPx: number; - fallbackFontSize: number; -}): TextBlockMeasurement { - let top = Number.POSITIVE_INFINITY; - let bottom = Number.NEGATIVE_INFINITY; - let maxWidth = 0; - - for (let i = 0; i < lineMetrics.length; i++) { - const metrics = lineMetrics[i]; - const y = i * lineHeightPx; - top = Math.min( - top, - y - getMetricAscent({ metrics, fallback: fallbackFontSize }), - ); - bottom = Math.max( - bottom, - y + getMetricDescent({ metrics, fallback: fallbackFontSize }), - ); - maxWidth = Math.max(maxWidth, metrics.width); - } - - const height = bottom - top; - const visualCenterOffset = (top + bottom) / 2; - - return { visualCenterOffset, height, maxWidth }; -} - function drawTextDecoration({ ctx, textDecoration, @@ -99,8 +52,8 @@ function drawTextDecoration({ if (textDecoration === "none" || !textDecoration) return; const thickness = Math.max(1, scaledFontSize * 0.07); - const ascent = getMetricAscent({ metrics, fallback: scaledFontSize }); - const descent = getMetricDescent({ metrics, fallback: scaledFontSize }); + const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize }); + const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize }); let xStart = -lineWidth / 2; if (textAlign === "left") xStart = 0; @@ -138,16 +91,28 @@ export class TextNode extends BaseNode<TextNodeParams> { 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; + const localTime = getElementLocalTime({ + timelineTime: time, + elementStartTime: this.params.startTime, + elementDuration: this.params.duration, + }); + const transform = resolveTransformAtTime({ + baseTransform: this.params.transform, + animations: this.params.animations, + localTime, + }); + const opacity = resolveOpacityAtTime({ + baseOpacity: this.params.opacity, + animations: this.params.animations, + localTime, + }); + const x = transform.position.x + this.params.canvasCenter.x; + const y = transform.position.y + this.params.canvasCenter.y; renderer.context.translate(x, y); - renderer.context.scale( - this.params.transform.scale, - this.params.transform.scale, - ); - if (this.params.transform.rotate) { - renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180); + renderer.context.scale(transform.scale, transform.scale); + if (transform.rotate) { + renderer.context.rotate((transform.rotate * Math.PI) / 180); } const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal"; @@ -171,6 +136,7 @@ export class TextNode extends BaseNode<TextNodeParams> { const lines = this.params.content.split("\n"); const lineHeightPx = scaledFontSize * lineHeight; + const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize; const baseline = this.params.textBaseline ?? "middle"; renderer.context.textBaseline = baseline; @@ -189,24 +155,33 @@ export class TextNode extends BaseNode<TextNodeParams> { ? this.params.blendMode : "source-over" ) as GlobalCompositeOperation; - renderer.context.globalAlpha = this.params.opacity; + renderer.context.globalAlpha = opacity; - if (this.params.backgroundColor && lineCount > 0) { - const padX = 8; - const padY = 4; - renderer.context.fillStyle = this.params.backgroundColor; - let bgLeft = -block.maxWidth / 2; - if (renderer.context.textAlign === "left") bgLeft = 0; - if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth; - - renderer.context.fillRect( - bgLeft - padX, - -block.height / 2 - padY, - block.maxWidth + padX * 2, - block.height + padY * 2, - ); - - renderer.context.fillStyle = this.params.color; + if ( + this.params.background.color && + this.params.background.color !== "transparent" && + lineCount > 0 + ) { + const { color, cornerRadius = 0 } = this.params.background; + const backgroundRect = getTextBackgroundRect({ + textAlign: this.params.textAlign, + block, + background: this.params.background, + fontSizeRatio, + }); + if (backgroundRect) { + renderer.context.fillStyle = color; + renderer.context.beginPath(); + renderer.context.roundRect( + backgroundRect.left, + backgroundRect.top, + backgroundRect.width, + backgroundRect.height, + cornerRadius, + ); + renderer.context.fill(); + renderer.context.fillStyle = this.params.color; + } } for (let i = 0; i < lineCount; i++) { diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts index 8e2ddccd..503c9c03 100644 --- a/apps/web/src/services/renderer/nodes/video-node.ts +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -12,11 +12,11 @@ export class VideoNode extends VisualNode<VideoNodeParams> { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { return; } - const videoTime = this.getLocalTime(time); + const videoTime = this.getSourceLocalTime({ time }); const frame = await videoCache.getFrameAt({ mediaId: this.params.mediaId, file: this.params.file, @@ -29,6 +29,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> { source: frame.canvas, sourceWidth: frame.canvas.width, sourceHeight: frame.canvas.height, + timelineTime: time, }); } } diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 7179369e..15a7b081 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -1,9 +1,18 @@ import type { CanvasRenderer } from "../canvas-renderer"; +import { createOffscreenCanvas } from "../canvas-utils"; import { BaseNode } from "./base-node"; +import type { Effect } from "@/types/effects"; import type { BlendMode } from "@/types/rendering"; import type { Transform } from "@/types/timeline"; - -const VISUAL_EPSILON = 1 / 1000; +import type { ElementAnimations } from "@/types/animation"; +import { + getElementLocalTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "@/lib/animation"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { getEffect } from "@/lib/effects"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; export interface VisualNodeParams { duration: number; @@ -11,21 +20,31 @@ export interface VisualNodeParams { trimStart: number; trimEnd: number; transform: Transform; + animations?: ElementAnimations; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export abstract class VisualNode< Params extends VisualNodeParams = VisualNodeParams, > extends BaseNode<Params> { - protected getLocalTime(time: number): number { + protected getSourceLocalTime({ time }: { time: number }): number { return time - this.params.timeOffset + this.params.trimStart; } - protected isInRange(time: number): boolean { - const localTime = this.getLocalTime(time); + protected getAnimationLocalTime({ time }: { time: number }): number { + return getElementLocalTime({ + timelineTime: time, + elementStartTime: this.params.timeOffset, + elementDuration: this.params.duration, + }); + } + + protected isInRange({ time }: { time: number }): boolean { + const localTime = this.getSourceLocalTime({ time }); return ( - localTime >= this.params.trimStart - VISUAL_EPSILON && + localTime >= this.params.trimStart - TIME_EPSILON_SECONDS && localTime < this.params.trimStart + this.params.duration ); } @@ -35,15 +54,27 @@ export abstract class VisualNode< source, sourceWidth, sourceHeight, + timelineTime, }: { renderer: CanvasRenderer; source: CanvasImageSource; sourceWidth: number; sourceHeight: number; + timelineTime: number; }): void { renderer.context.save(); - const { transform, opacity } = this.params; + const animationLocalTime = this.getAnimationLocalTime({ time: timelineTime }); + const transform = resolveTransformAtTime({ + baseTransform: this.params.transform, + animations: this.params.animations, + localTime: animationLocalTime, + }); + const opacity = resolveOpacityAtTime({ + baseOpacity: this.params.opacity, + animations: this.params.animations, + localTime: animationLocalTime, + }); const containScale = Math.min( renderer.width / sourceWidth, renderer.height / sourceHeight, @@ -68,7 +99,58 @@ export abstract class VisualNode< renderer.context.translate(-centerX, -centerY); } - renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + const enabledEffects = + this.params.effects?.filter((effect) => effect.enabled) ?? []; + + if (enabledEffects.length === 0) { + renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + renderer.context.restore(); + return; + } + + const elementCanvas = createOffscreenCanvas({ + width: Math.round(scaledWidth), + height: Math.round(scaledHeight), + }); + const elementCtx = elementCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!elementCtx) { + renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + renderer.context.restore(); + return; + } + + elementCtx.drawImage(source, 0, 0, scaledWidth, scaledHeight); + + let currentResult: CanvasImageSource = elementCanvas; + + for (const effect of enabledEffects) { + const definition = getEffect({ effectType: effect.type }); + const passes = definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: effect.params, + width: scaledWidth, + height: scaledHeight, + }), + })); + currentResult = webglEffectRenderer.applyEffect({ + source: currentResult, + width: Math.round(scaledWidth), + height: Math.round(scaledHeight), + passes, + }); + } + + renderer.context.drawImage( + currentResult, + 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 ee7f3d6c..695e077f 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -6,48 +6,59 @@ import { ImageNode } from "./nodes/image-node"; import { TextNode } from "./nodes/text-node"; import { StickerNode } from "./nodes/sticker-node"; import { ColorNode } from "./nodes/color-node"; -import { BlurBackgroundNode } from "./nodes/blur-background-node"; +import { CompositeEffectNode } from "./nodes/composite-effect-node"; +import { EffectLayerNode } from "./nodes/effect-layer-node"; +import type { BaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/types/project"; import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; import { isMainTrack } from "@/lib/timeline"; -export type BuildSceneParams = { - canvasSize: TCanvasSize; - tracks: TimelineTrack[]; - mediaAssets: MediaAsset[]; - duration: number; - background: TBackground; -}; - -export function buildScene(params: BuildSceneParams) { - const { tracks, mediaAssets, duration, canvasSize, background } = params; - - const rootNode = new RootNode({ duration }); - const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); - - const visibleTracks = tracks.filter( - (track) => !("hidden" in track && track.hidden), - ); +const PREVIEW_MAX_IMAGE_SIZE = 2048; +const BLUR_BACKGROUND_ZOOM_SCALE = 1.4; - const orderedTracksTopToBottom = [ - ...visibleTracks.filter((track) => !isMainTrack(track)), - ...visibleTracks.filter((track) => isMainTrack(track)), - ]; - - const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); +function getVisibleSortedElements({ + track, +}: { + track: TimelineTrack; +}) { + return track.elements + .filter((element) => !("hidden" in element && element.hidden)) + .slice() + .sort((a, b) => { + if (a.startTime !== b.startTime) return a.startTime - b.startTime; + return a.id.localeCompare(b.id); + }); +} - const contentNodes = []; +function buildTrackNodes({ + tracks, + mediaMap, + canvasSize, + isPreview, +}: { + tracks: TimelineTrack[]; + mediaMap: Map<string, MediaAsset>; + canvasSize: TCanvasSize; + isPreview?: boolean; +}): BaseNode[] { + const nodes: BaseNode[] = []; - for (const track of orderedTracksBottomToTop) { - const elements = track.elements - .filter((element) => !("hidden" in element && element.hidden)) - .slice() - .sort((a, b) => { - if (a.startTime !== b.startTime) return a.startTime - b.startTime; - return a.id.localeCompare(b.id); - }); + for (const track of tracks) { + const elements = getVisibleSortedElements({ track }); for (const element of elements) { + if (element.type === "effect") { + nodes.push( + new EffectLayerNode({ + effectType: element.effectType, + effectParams: element.params, + timeOffset: element.startTime, + duration: element.duration, + }), + ); + continue; + } + if (element.type === "video" || element.type === "image") { const mediaAsset = mediaMap.get(element.mediaId); if (!mediaAsset?.file || !mediaAsset?.url) { @@ -55,7 +66,7 @@ export function buildScene(params: BuildSceneParams) { } if (mediaAsset.type === "video") { - contentNodes.push( + nodes.push( new VideoNode({ mediaId: mediaAsset.id, url: mediaAsset.url, @@ -65,13 +76,15 @@ export function buildScene(params: BuildSceneParams) { trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, + animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, + effects: element.effects, }), ); } if (mediaAsset.type === "image") { - contentNodes.push( + nodes.push( new ImageNode({ url: mediaAsset.url, duration: element.duration, @@ -79,26 +92,32 @@ export function buildScene(params: BuildSceneParams) { trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, + animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, + effects: element.effects, + ...(isPreview && { + maxSourceSize: PREVIEW_MAX_IMAGE_SIZE, + }), }), ); } } if (element.type === "text") { - contentNodes.push( + nodes.push( new TextNode({ ...element, canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, canvasHeight: canvasSize.height, textBaseline: "middle", + effects: element.effects, }), ); } if (element.type === "sticker") { - contentNodes.push( + nodes.push( new StickerNode({ stickerId: element.stickerId, duration: element.duration, @@ -106,31 +125,77 @@ export function buildScene(params: BuildSceneParams) { trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, + animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, + effects: element.effects, }), ); } } } + return nodes; +} + +export type BuildSceneParams = { + canvasSize: TCanvasSize; + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + duration: number; + background: TBackground; + isPreview?: boolean; +}; + +export function buildScene({ + canvasSize, + tracks, + mediaAssets, + duration, + background, + isPreview, +}: BuildSceneParams) { + const rootNode = new RootNode({ duration }); + const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); + + const visibleTracks = tracks.filter( + (track) => !("hidden" in track && track.hidden), + ); + + const orderedTracksTopToBottom = [ + ...visibleTracks.filter((track) => !isMainTrack(track)), + ...visibleTracks.filter((track) => isMainTrack(track)), + ]; + + const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); + + const allNodes = buildTrackNodes({ + tracks: orderedTracksBottomToTop, + mediaMap, + canvasSize, + isPreview, + }); + if (background.type === "blur") { rootNode.add( - new BlurBackgroundNode({ - blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, - contentNodes, + new CompositeEffectNode({ + contentNodes: allNodes.filter( + (node) => !(node instanceof EffectLayerNode), + ), + effectType: "blur", + effectParams: { + intensity: + background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, + }, + scale: BLUR_BACKGROUND_ZOOM_SCALE, }), ); - for (const node of contentNodes) { - rootNode.add(node); - } - } else { - if (background.type === "color" && background.color !== "transparent") { - rootNode.add(new ColorNode({ color: background.color })); - } - for (const node of contentNodes) { - rootNode.add(node); - } + } else if (background.type === "color" && background.color !== "transparent") { + rootNode.add(new ColorNode({ color: background.color })); + } + + for (const node of allNodes) { + rootNode.add(node); } return rootNode; diff --git a/apps/web/src/services/renderer/scene-exporter.ts b/apps/web/src/services/renderer/scene-exporter.ts index 39e94e55..a715efe7 100644 --- a/apps/web/src/services/renderer/scene-exporter.ts +++ b/apps/web/src/services/renderer/scene-exporter.ts @@ -13,11 +13,9 @@ import { QUALITY_VERY_HIGH, } from "mediabunny"; import type { RootNode } from "./nodes/root-node"; +import type { ExportFormat, ExportQuality } from "@/types/export"; import { CanvasRenderer } from "./canvas-renderer"; -export type ExportFormat = "mp4" | "webm"; -export type ExportQuality = "low" | "medium" | "high" | "very_high"; - type ExportParams = { width: number; height: number; diff --git a/apps/web/src/services/renderer/webgl-effect-renderer.ts b/apps/web/src/services/renderer/webgl-effect-renderer.ts new file mode 100644 index 00000000..ad5cd40e --- /dev/null +++ b/apps/web/src/services/renderer/webgl-effect-renderer.ts @@ -0,0 +1,73 @@ +import { createOffscreenCanvas } from "./canvas-utils"; +import { applyMultiPassEffect } from "./webgl-utils"; +import type { EffectPassData } from "./webgl-utils"; + +export interface ApplyEffectParams { + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; +} + +let gl: WebGLRenderingContext | null = null; +let canvas: OffscreenCanvas | HTMLCanvasElement | null = null; +const programCache = new Map<string, WebGLProgram>(); + +function getOrCreateCanvas({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement { + if (!canvas) { + canvas = createOffscreenCanvas({ width, height }); + gl = canvas.getContext("webgl", { + premultipliedAlpha: false, + }) as WebGLRenderingContext | null; + if (!gl) { + throw new Error("WebGL not supported"); + } + } + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + return canvas; +} + +function applyEffect({ + source, + width, + height, + passes, +}: ApplyEffectParams): OffscreenCanvas | HTMLCanvasElement { + const targetCanvas = getOrCreateCanvas({ width, height }); + const context = gl; + if (!context) { + throw new Error("WebGL context not initialized"); + } + + applyMultiPassEffect({ + context, + source, + width, + height, + passes, + programCache, + }); + + const outputCanvas = createOffscreenCanvas({ width, height }); + const outputCtx = outputCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (outputCtx) { + outputCtx.drawImage(targetCanvas, 0, 0, width, height); + } + return outputCanvas; +} + +export const webglEffectRenderer = { + applyEffect, +}; diff --git a/apps/web/src/services/renderer/webgl-utils.ts b/apps/web/src/services/renderer/webgl-utils.ts new file mode 100644 index 00000000..834c652e --- /dev/null +++ b/apps/web/src/services/renderer/webgl-utils.ts @@ -0,0 +1,298 @@ +import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl"; + +export interface EffectPassData { + fragmentShader: string; + uniforms: Record<string, number | number[]>; +} + +export const QUAD_POSITIONS = new Float32Array([ + -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, +]); + +export function compileProgram({ + context, + fragmentShaderSource, + programCache, +}: { + context: WebGLRenderingContext; + fragmentShaderSource: string; + programCache: Map<string, WebGLProgram>; +}): WebGLProgram { + const cached = programCache.get(fragmentShaderSource); + if (cached) { + return cached; + } + const vertexShader = compileShader({ + context, + source: VERTEX_SHADER_SOURCE, + type: context.VERTEX_SHADER, + }); + const fragmentShader = compileShader({ + context, + source: fragmentShaderSource, + type: context.FRAGMENT_SHADER, + }); + const program = context.createProgram(); + if (!program) { + throw new Error("Failed to create WebGL program"); + } + context.attachShader(program, vertexShader); + context.attachShader(program, fragmentShader); + context.linkProgram(program); + if (!context.getProgramParameter(program, context.LINK_STATUS)) { + const info = context.getProgramInfoLog(program); + context.deleteProgram(program); + throw new Error(`WebGL program link failed: ${info}`); + } + context.deleteShader(vertexShader); + context.deleteShader(fragmentShader); + programCache.set(fragmentShaderSource, program); + return program; +} + +export function compileShader({ + context, + source, + type, +}: { + context: WebGLRenderingContext; + source: string; + type: number; +}): WebGLShader { + const shader = context.createShader(type); + if (!shader) { + throw new Error("Failed to create WebGL shader"); + } + context.shaderSource(shader, source); + context.compileShader(shader); + if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) { + const info = context.getShaderInfoLog(shader); + context.deleteShader(shader); + throw new Error(`WebGL shader compile failed: ${info}`); + } + return shader; +} + +export function createTexture({ + context, + source, +}: { + context: WebGLRenderingContext; + source: CanvasImageSource; +}): WebGLTexture { + const texture = context.createTexture(); + if (!texture) { + throw new Error("Failed to create WebGL texture"); + } + context.activeTexture(context.TEXTURE0); + context.bindTexture(context.TEXTURE_2D, texture); + context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, 1); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_S, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_T, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MIN_FILTER, + context.LINEAR, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MAG_FILTER, + context.LINEAR, + ); + context.texImage2D( + context.TEXTURE_2D, + 0, + context.RGBA, + context.RGBA, + context.UNSIGNED_BYTE, + source as TexImageSource, + ); + return texture; +} + +export function setUniforms({ + context, + program, + uniforms, +}: { + context: WebGLRenderingContext; + program: WebGLProgram; + uniforms: Record<string, number | number[]>; +}): void { + for (const [name, value] of Object.entries(uniforms)) { + const location = context.getUniformLocation(program, name); + if (location === null) continue; + + if (typeof value === "number") { + context.uniform1f(location, value); + } else if (Array.isArray(value)) { + if (value.length === 2) { + context.uniform2fv(location, new Float32Array(value)); + } else if (value.length === 3) { + context.uniform3fv(location, new Float32Array(value)); + } else if (value.length === 4) { + context.uniform4fv(location, new Float32Array(value)); + } + } + } +} + +export function drawFullscreenQuad({ + context, + program, + width, + height, +}: { + context: WebGLRenderingContext; + program: WebGLProgram; + width: number; + height: number; +}): void { + const positionLocation = context.getAttribLocation(program, "a_position"); + const buffer = context.createBuffer(); + context.bindBuffer(context.ARRAY_BUFFER, buffer); + context.bufferData(context.ARRAY_BUFFER, QUAD_POSITIONS, context.STATIC_DRAW); + context.enableVertexAttribArray(positionLocation); + context.vertexAttribPointer(positionLocation, 2, context.FLOAT, false, 0, 0); + + context.viewport(0, 0, width, height); + context.clearColor(0, 0, 0, 0); + context.clear(context.COLOR_BUFFER_BIT); + context.drawArrays(context.TRIANGLES, 0, 6); +} + +export function createFramebufferTexture({ + context, + width, + height, +}: { + context: WebGLRenderingContext; + width: number; + height: number; +}): { texture: WebGLTexture; framebuffer: WebGLFramebuffer } { + const texture = context.createTexture(); + if (!texture) throw new Error("Failed to create framebuffer texture"); + context.bindTexture(context.TEXTURE_2D, texture); + context.texImage2D( + context.TEXTURE_2D, + 0, + context.RGBA, + width, + height, + 0, + context.RGBA, + context.UNSIGNED_BYTE, + null, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_S, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_T, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MIN_FILTER, + context.LINEAR, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MAG_FILTER, + context.LINEAR, + ); + context.bindTexture(context.TEXTURE_2D, null); + + const framebuffer = context.createFramebuffer(); + if (!framebuffer) throw new Error("Failed to create framebuffer"); + context.bindFramebuffer(context.FRAMEBUFFER, framebuffer); + context.framebufferTexture2D( + context.FRAMEBUFFER, + context.COLOR_ATTACHMENT0, + context.TEXTURE_2D, + texture, + 0, + ); + context.bindFramebuffer(context.FRAMEBUFFER, null); + + return { texture, framebuffer }; +} + +export function applyMultiPassEffect({ + context, + source, + width, + height, + passes, + programCache, +}: { + context: WebGLRenderingContext; + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; + programCache: Map<string, WebGLProgram>; +}): void { + const sourceTexture = createTexture({ context, source }); + let currentTexture: WebGLTexture = sourceTexture; + + const intermediates: Array<{ + texture: WebGLTexture; + framebuffer: WebGLFramebuffer; + }> = []; + for (let i = 0; i < passes.length - 1; i++) { + intermediates.push(createFramebufferTexture({ context, width, height })); + } + + for (let i = 0; i < passes.length; i++) { + const pass = passes[i]; + const program = compileProgram({ + context, + fragmentShaderSource: pass.fragmentShader, + programCache, + }); + const isLastPass = i === passes.length - 1; + const targetFramebuffer = isLastPass ? null : intermediates[i].framebuffer; + + context.bindFramebuffer(context.FRAMEBUFFER, targetFramebuffer); + // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook + context.useProgram(program); + context.activeTexture(context.TEXTURE0); + context.bindTexture(context.TEXTURE_2D, currentTexture); + + const uTextureLocation = context.getUniformLocation(program, "u_texture"); + if (uTextureLocation) { + context.uniform1i(uTextureLocation, 0); + } + + setUniforms({ + context, + program, + uniforms: { ...pass.uniforms, u_resolution: [width, height] }, + }); + drawFullscreenQuad({ context, program, width, height }); + + if (!isLastPass) { + currentTexture = intermediates[i].texture; + } + } + + context.deleteTexture(sourceTexture); + for (const intermediate of intermediates) { + context.deleteTexture(intermediate.texture); + context.deleteFramebuffer(intermediate.framebuffer); + } + context.bindTexture(context.TEXTURE_2D, null); + context.bindFramebuffer(context.FRAMEBUFFER, null); +} diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index 00c009c7..86a202b2 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -5,10 +5,12 @@ import { V2toV3Migration } from "./v2-to-v3"; import { V3toV4Migration } from "./v3-to-v4"; import { V4toV5Migration } from "./v4-to-v5"; import { V5toV6Migration } from "./v5-to-v6"; +import { V6toV7Migration } from "./v6-to-v7"; +import { V7toV8Migration } from "./v7-to-v8"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 6; +export const CURRENT_PROJECT_VERSION = 8; export const migrations = [ new V0toV1Migration(), @@ -17,4 +19,6 @@ export const migrations = [ new V3toV4Migration(), new V4toV5Migration(), new V5toV6Migration(), + new V6toV7Migration(), + new V7toV8Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts index 098101d6..d16106d9 100644 --- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -450,10 +450,17 @@ function transformTextTrack({ value: textElement.color, fallback: "#000000", }), - backgroundColor: getStringValue({ + background: { + color: getStringValue({ value: textElement.backgroundColor, - fallback: "#FFFFFF", + fallback: "transparent", }), + cornerRadius: 0, + paddingX: 8, + paddingY: 4, + offsetX: 0, + offsetY: 0, + }, textAlign: (getStringValue({ value: textElement.textAlign, fallback: "left", diff --git a/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts b/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts new file mode 100644 index 00000000..6f4848d4 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts @@ -0,0 +1,106 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV6ToV7({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV7Project({ project })) { + return { project, skipped: true, reason: "already v7" }; + } + + const migratedProject = migrateProjectTextElements({ project }); + + return { + project: { ...migratedProject, version: 7 }, + skipped: false, + }; +} + +function migrateProjectTextElements({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + let hasChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migrated = migrateSceneTextElements({ scene }); + if (migrated !== scene) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return project; + return { ...project, scenes: migratedScenes }; +} + +function migrateSceneTextElements({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + let hasChanges = false; + const migratedTracks = tracksValue.map((track) => { + const migrated = migrateTrackTextElements({ track }); + if (migrated !== track) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return scene; + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackTextElements({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + let hasChanges = false; + const migratedElements = elementsValue.map((element) => { + const migrated = migrateTextElement({ element }); + if (migrated !== element) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return track; + return { ...track, elements: migratedElements }; +} + +function migrateTextElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + if (element.type !== "text") return element; + if (isRecord(element.background)) return element; + + const backgroundColor = + typeof element.backgroundColor === "string" + ? element.backgroundColor + : "transparent"; + + const { backgroundColor: _removed, ...rest } = element; + + return { + ...rest, + background: { + color: backgroundColor, + cornerRadius: 0, + paddingX: 8, + paddingY: 4, + offsetX: 0, + offsetY: 0, + }, + }; +} + +function isV7Project({ project }: { project: ProjectRecord }): boolean { + return typeof project.version === "number" && project.version >= 7; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts new file mode 100644 index 00000000..7393f776 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts @@ -0,0 +1,96 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV7ToV8({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV8Project({ project })) { + return { project, skipped: true, reason: "already v8" }; + } + + const migratedProject = migrateProjectElements({ project }); + + return { + project: { ...migratedProject, version: 8 }, + skipped: false, + }; +} + +function migrateProjectElements({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + let hasChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migrated = migrateSceneElements({ scene }); + if (migrated !== scene) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return project; + return { ...project, scenes: migratedScenes }; +} + +function migrateSceneElements({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + let hasChanges = false; + const migratedTracks = tracksValue.map((track) => { + const migrated = migrateTrackElements({ track }); + if (migrated !== track) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return scene; + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackElements({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + let hasChanges = false; + const migratedElements = elementsValue.map((element) => { + const migrated = migrateElement({ element }); + if (migrated !== element) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return track; + return { ...track, elements: migratedElements }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + if (element.type !== "video" && element.type !== "audio") return element; + if (typeof element.sourceDuration === "number") return element; + + const trimStart = typeof element.trimStart === "number" ? element.trimStart : 0; + const duration = typeof element.duration === "number" ? element.duration : 0; + const trimEnd = typeof element.trimEnd === "number" ? element.trimEnd : 0; + + return { + ...element, + sourceDuration: trimStart + duration + trimEnd, + }; +} + +function isV8Project({ project }: { project: ProjectRecord }): boolean { + return typeof project.version === "number" && project.version >= 8; +} diff --git a/apps/web/src/services/storage/migrations/v6-to-v7.ts b/apps/web/src/services/storage/migrations/v6-to-v7.ts new file mode 100644 index 00000000..0556b4d5 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v6-to-v7.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV6ToV7 } from "./transformers/v6-to-v7"; + +export class V6toV7Migration extends StorageMigration { + from = 6; + to = 7; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV6ToV7({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v7-to-v8.ts b/apps/web/src/services/storage/migrations/v7-to-v8.ts new file mode 100644 index 00000000..33372c0a --- /dev/null +++ b/apps/web/src/services/storage/migrations/v7-to-v8.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV7ToV8 } from "./transformers/v7-to-v8"; + +export class V7toV8Migration extends StorageMigration { + from = 7; + to = 8; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV7ToV8({ project }); + } +} |
