From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/services/renderer/canvas-utils.ts | 32 +- apps/web/src/services/renderer/mask-feather.ts | 108 ++-- .../services/renderer/nodes/effect-layer-node.ts | 162 +++--- .../src/services/renderer/nodes/graphic-node.ts | 184 +++---- apps/web/src/services/renderer/nodes/video-node.ts | 74 +-- apps/web/src/services/renderer/scene-builder.ts | 544 +++++++++---------- apps/web/src/services/renderer/scene-exporter.ts | 326 +++++------ apps/web/src/services/renderer/webgl/jfa.ts | 388 +++++++------- .../src/services/renderer/webgl/webgl-context.ts | 98 ++-- .../renderer/webgl/webgl-effect-renderer.ts | 70 +-- .../web/src/services/renderer/webgl/webgl-utils.ts | 596 ++++++++++----------- .../storage/migrations/__tests__/fixtures/index.ts | 20 +- .../storage/migrations/__tests__/fixtures/v5.ts | 108 ++-- .../migrations/__tests__/v15-to-v16.test.ts | 186 +++---- .../migrations/__tests__/v16-to-v17.test.ts | 264 ++++----- .../migrations/__tests__/v18-to-v19.test.ts | 126 ++--- .../storage/migrations/__tests__/v3-to-v4.test.ts | 280 +++++----- .../storage/migrations/__tests__/v4-to-v5.test.ts | 274 +++++----- .../storage/migrations/__tests__/v5-to-v6.test.ts | 184 +++---- .../storage/migrations/__tests__/v8-to-v9.test.ts | 350 ++++++------ apps/web/src/services/storage/migrations/base.ts | 18 +- apps/web/src/services/storage/migrations/index.ts | 92 ++-- apps/web/src/services/storage/migrations/runner.ts | 310 +++++------ .../storage/migrations/transformers/v10-to-v11.ts | 226 ++++---- .../storage/migrations/transformers/v11-to-v12.ts | 392 +++++++------- .../storage/migrations/transformers/v12-to-v13.ts | 204 +++---- .../storage/migrations/transformers/v13-to-v14.ts | 212 ++++---- .../storage/migrations/transformers/v14-to-v15.ts | 134 ++--- .../storage/migrations/transformers/v15-to-v16.ts | 120 ++--- .../storage/migrations/transformers/v16-to-v17.ts | 214 ++++---- .../storage/migrations/transformers/v17-to-v18.ts | 210 ++++---- .../storage/migrations/transformers/v18-to-v19.ts | 92 ++-- .../storage/migrations/transformers/v3-to-v4.ts | 378 ++++++------- .../storage/migrations/transformers/v4-to-v5.ts | 354 ++++++------ .../storage/migrations/transformers/v5-to-v6.ts | 176 +++--- .../storage/migrations/transformers/v6-to-v7.ts | 212 ++++---- .../storage/migrations/transformers/v7-to-v8.ts | 192 +++---- .../storage/migrations/transformers/v8-to-v9.ts | 198 +++---- .../storage/migrations/transformers/v9-to-v10.ts | 42 +- .../src/services/storage/migrations/v10-to-v11.ts | 32 +- .../src/services/storage/migrations/v11-to-v12.ts | 32 +- .../src/services/storage/migrations/v12-to-v13.ts | 32 +- .../src/services/storage/migrations/v13-to-v14.ts | 32 +- .../src/services/storage/migrations/v14-to-v15.ts | 32 +- .../src/services/storage/migrations/v15-to-v16.ts | 32 +- .../src/services/storage/migrations/v16-to-v17.ts | 32 +- .../src/services/storage/migrations/v17-to-v18.ts | 32 +- .../src/services/storage/migrations/v18-to-v19.ts | 32 +- .../src/services/storage/migrations/v3-to-v4.ts | 32 +- .../src/services/storage/migrations/v4-to-v5.ts | 32 +- .../src/services/storage/migrations/v5-to-v6.ts | 32 +- .../src/services/storage/migrations/v6-to-v7.ts | 32 +- .../src/services/storage/migrations/v7-to-v8.ts | 32 +- .../src/services/storage/migrations/v8-to-v9.ts | 32 +- .../src/services/storage/migrations/v9-to-v10.ts | 32 +- apps/web/src/services/storage/quota.ts | 290 +++++----- apps/web/src/services/transcription/service.ts | 372 ++++++------- apps/web/src/services/transcription/worker.ts | 352 ++++++------ 58 files changed, 4838 insertions(+), 4838 deletions(-) (limited to 'apps/web/src/services') diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts index 093168e6..ea04037b 100644 --- a/apps/web/src/services/renderer/canvas-utils.ts +++ b/apps/web/src/services/renderer/canvas-utils.ts @@ -1,16 +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; - } -} +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/mask-feather.ts b/apps/web/src/services/renderer/mask-feather.ts index 39ad80a8..f1515117 100644 --- a/apps/web/src/services/renderer/mask-feather.ts +++ b/apps/web/src/services/renderer/mask-feather.ts @@ -1,54 +1,54 @@ -import jfaDistanceShader from "@/lib/masks/shaders/jfa-distance.frag.glsl"; -import { getWebGLContext, readResult } from "./webgl/webgl-context"; -import { computeSignedDistanceField, runPass } from "./webgl/jfa"; -import { compileProgram, createTexture } from "./webgl/webgl-utils"; - -export function applyMaskFeather({ - maskCanvas, - width, - height, - feather, -}: { - maskCanvas: CanvasImageSource; - width: number; - height: number; - feather: number; -}): OffscreenCanvas | HTMLCanvasElement { - const { context, programCache } = getWebGLContext({ width, height }); - const sourceTexture = createTexture({ context, source: maskCanvas }); - - const sdf = computeSignedDistanceField({ - context, - programCache, - sourceTexture, - width, - height, - }); - - const distanceProgram = compileProgram({ - context, - fragmentShaderSource: jfaDistanceShader, - programCache, - }); - - runPass({ - context, - program: distanceProgram, - inputTexture: sdf.insideTexture, - target: null, - width, - height, - uniforms: { u_feather_half: feather / 2.0 }, - extraBindings: [ - { unit: 1, texture: sdf.outsideTexture, name: "u_jfa_outside" }, - ], - }); - - context.deleteTexture(sourceTexture); - sdf.cleanup(); - - context.bindTexture(context.TEXTURE_2D, null); - context.bindFramebuffer(context.FRAMEBUFFER, null); - - return readResult({ width, height }); -} +import jfaDistanceShader from "@/lib/masks/shaders/jfa-distance.frag.glsl"; +import { getWebGLContext, readResult } from "./webgl/webgl-context"; +import { computeSignedDistanceField, runPass } from "./webgl/jfa"; +import { compileProgram, createTexture } from "./webgl/webgl-utils"; + +export function applyMaskFeather({ + maskCanvas, + width, + height, + feather, +}: { + maskCanvas: CanvasImageSource; + width: number; + height: number; + feather: number; +}): OffscreenCanvas | HTMLCanvasElement { + const { context, programCache } = getWebGLContext({ width, height }); + const sourceTexture = createTexture({ context, source: maskCanvas }); + + const sdf = computeSignedDistanceField({ + context, + programCache, + sourceTexture, + width, + height, + }); + + const distanceProgram = compileProgram({ + context, + fragmentShaderSource: jfaDistanceShader, + programCache, + }); + + runPass({ + context, + program: distanceProgram, + inputTexture: sdf.insideTexture, + target: null, + width, + height, + uniforms: { u_feather_half: feather / 2.0 }, + extraBindings: [ + { unit: 1, texture: sdf.outsideTexture, name: "u_jfa_outside" }, + ], + }); + + context.deleteTexture(sourceTexture); + sdf.cleanup(); + + context.bindTexture(context.TEXTURE_2D, null); + context.bindFramebuffer(context.FRAMEBUFFER, null); + + return readResult({ width, height }); +} diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts index 82aee1b3..51de2398 100644 --- a/apps/web/src/services/renderer/nodes/effect-layer-node.ts +++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts @@ -1,81 +1,81 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { effectsRegistry, resolveEffectPasses } from "@/lib/effects"; -import type { ParamValues } from "@/lib/params"; -import { BaseNode } from "./base-node"; -import { webglEffectRenderer } from "../webgl/webgl-effect-renderer"; - -const TIME_EPSILON = 1e-6; - -export type EffectLayerNodeParams = { - effectType: string; - effectParams: ParamValues; - 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 { - async render({ - renderer, - time, - }: { - renderer: CanvasRenderer; - time: number; - }): Promise { - if ( - !isInRange({ - time, - timeOffset: this.params.timeOffset, - duration: this.params.duration, - }) - ) { - return; - } - - const source = renderer.context.canvas as CanvasImageSource; - - const effectDefinition = effectsRegistry.get(this.params.effectType); - - const passes = resolveEffectPasses({ - definition: effectDefinition, - effectParams: this.params.effectParams, - width: renderer.width, - height: renderer.height, - }); - if (passes.length === 0) { - return; - } - 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(); - } -} +import type { CanvasRenderer } from "../canvas-renderer"; +import { effectsRegistry, resolveEffectPasses } from "@/lib/effects"; +import type { ParamValues } from "@/lib/params"; +import { BaseNode } from "./base-node"; +import { webglEffectRenderer } from "../webgl/webgl-effect-renderer"; + +const TIME_EPSILON = 1e-6; + +export type EffectLayerNodeParams = { + effectType: string; + effectParams: ParamValues; + 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 { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise { + if ( + !isInRange({ + time, + timeOffset: this.params.timeOffset, + duration: this.params.duration, + }) + ) { + return; + } + + const source = renderer.context.canvas as CanvasImageSource; + + const effectDefinition = effectsRegistry.get(this.params.effectType); + + const passes = resolveEffectPasses({ + definition: effectDefinition, + effectParams: this.params.effectParams, + width: renderer.width, + height: renderer.height, + }); + if (passes.length === 0) { + return; + } + 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/graphic-node.ts b/apps/web/src/services/renderer/nodes/graphic-node.ts index b258a353..0a3dc408 100644 --- a/apps/web/src/services/renderer/nodes/graphic-node.ts +++ b/apps/web/src/services/renderer/nodes/graphic-node.ts @@ -1,92 +1,92 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { createOffscreenCanvas } from "../canvas-utils"; -import { - DEFAULT_GRAPHIC_SOURCE_SIZE, - getGraphicDefinition, - registerDefaultGraphics, -} from "@/lib/graphics"; -import { resolveGraphicParamsAtTime } from "@/lib/animation"; -import type { ParamValues } from "@/lib/params"; -import { VisualNode, type VisualNodeParams } from "./visual-node"; - -export interface GraphicNodeParams extends VisualNodeParams { - definitionId: string; - params: ParamValues; -} - -export class GraphicNode extends VisualNode { - private cachedKey: string | null = null; - private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null; - - constructor(params: GraphicNodeParams) { - super(params); - registerDefaultGraphics(); - } - - private getSource({ - localTime, - }: { - localTime: number; - }): OffscreenCanvas | HTMLCanvasElement | null { - const definition = getGraphicDefinition({ - definitionId: this.params.definitionId, - }); - const resolvedParams = resolveGraphicParamsAtTime({ - element: this.params, - localTime, - }); - const cacheKey = JSON.stringify({ - definitionId: this.params.definitionId, - params: resolvedParams, - }); - if (this.cachedSource && this.cachedKey === cacheKey) { - return this.cachedSource; - } - - const canvas = createOffscreenCanvas({ - width: DEFAULT_GRAPHIC_SOURCE_SIZE, - height: DEFAULT_GRAPHIC_SOURCE_SIZE, - }); - const ctx = canvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!ctx) { - return null; - } - - definition.render({ - ctx, - params: resolvedParams, - width: DEFAULT_GRAPHIC_SOURCE_SIZE, - height: DEFAULT_GRAPHIC_SOURCE_SIZE, - }); - - this.cachedKey = cacheKey; - this.cachedSource = canvas; - return canvas; - } - - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { - await super.render({ renderer, time }); - - if (!this.isInRange({ time })) { - return; - } - - const source = this.getSource({ - localTime: this.getAnimationLocalTime({ time }), - }); - if (!source) { - return; - } - - this.renderVisual({ - renderer, - source, - sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, - sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, - timelineTime: time, - }); - } -} +import type { CanvasRenderer } from "../canvas-renderer"; +import { createOffscreenCanvas } from "../canvas-utils"; +import { + DEFAULT_GRAPHIC_SOURCE_SIZE, + getGraphicDefinition, + registerDefaultGraphics, +} from "@/lib/graphics"; +import { resolveGraphicParamsAtTime } from "@/lib/animation"; +import type { ParamValues } from "@/lib/params"; +import { VisualNode, type VisualNodeParams } from "./visual-node"; + +export interface GraphicNodeParams extends VisualNodeParams { + definitionId: string; + params: ParamValues; +} + +export class GraphicNode extends VisualNode { + private cachedKey: string | null = null; + private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null; + + constructor(params: GraphicNodeParams) { + super(params); + registerDefaultGraphics(); + } + + private getSource({ + localTime, + }: { + localTime: number; + }): OffscreenCanvas | HTMLCanvasElement | null { + const definition = getGraphicDefinition({ + definitionId: this.params.definitionId, + }); + const resolvedParams = resolveGraphicParamsAtTime({ + element: this.params, + localTime, + }); + const cacheKey = JSON.stringify({ + definitionId: this.params.definitionId, + params: resolvedParams, + }); + if (this.cachedSource && this.cachedKey === cacheKey) { + return this.cachedSource; + } + + const canvas = createOffscreenCanvas({ + width: DEFAULT_GRAPHIC_SOURCE_SIZE, + height: DEFAULT_GRAPHIC_SOURCE_SIZE, + }); + const ctx = canvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!ctx) { + return null; + } + + definition.render({ + ctx, + params: resolvedParams, + width: DEFAULT_GRAPHIC_SOURCE_SIZE, + height: DEFAULT_GRAPHIC_SOURCE_SIZE, + }); + + this.cachedKey = cacheKey; + this.cachedSource = canvas; + return canvas; + } + + async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { + await super.render({ renderer, time }); + + if (!this.isInRange({ time })) { + return; + } + + const source = this.getSource({ + localTime: this.getAnimationLocalTime({ time }), + }); + if (!source) { + return; + } + + this.renderVisual({ + renderer, + source, + sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, + sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, + timelineTime: time, + }); + } +} diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts index af39d26f..3bb836ec 100644 --- a/apps/web/src/services/renderer/nodes/video-node.ts +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -1,37 +1,37 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { VisualNode, type VisualNodeParams } from "./visual-node"; -import { videoCache } from "@/services/video-cache/service"; - -export interface VideoNodeParams extends VisualNodeParams { - url: string; - file: File; - mediaId: string; -} - -export class VideoNode extends VisualNode { - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { - await super.render({ renderer, time }); - - if (!this.isInRange({ time })) { - return; - } - - const videoTime = this.getSourceLocalTime({ time }); - - const frame = await videoCache.getFrameAt({ - mediaId: this.params.mediaId, - file: this.params.file, - time: videoTime, - }); - - if (frame) { - this.renderVisual({ - renderer, - source: frame.canvas, - sourceWidth: frame.canvas.width, - sourceHeight: frame.canvas.height, - timelineTime: time, - }); - } - } -} +import type { CanvasRenderer } from "../canvas-renderer"; +import { VisualNode, type VisualNodeParams } from "./visual-node"; +import { videoCache } from "@/services/video-cache/service"; + +export interface VideoNodeParams extends VisualNodeParams { + url: string; + file: File; + mediaId: string; +} + +export class VideoNode extends VisualNode { + async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { + await super.render({ renderer, time }); + + if (!this.isInRange({ time })) { + return; + } + + const videoTime = this.getSourceLocalTime({ time }); + + const frame = await videoCache.getFrameAt({ + mediaId: this.params.mediaId, + file: this.params.file, + time: videoTime, + }); + + if (frame) { + this.renderVisual({ + renderer, + source: frame.canvas, + sourceWidth: frame.canvas.width, + sourceHeight: frame.canvas.height, + timelineTime: time, + }); + } + } +} diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 7d5f9fe9..1c3e6ad2 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -1,272 +1,272 @@ -import type { TimelineTrack } from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; -import { RootNode } from "./nodes/root-node"; -import { VideoNode } from "./nodes/video-node"; -import { ImageNode } from "./nodes/image-node"; -import { TextNode } from "./nodes/text-node"; -import { StickerNode } from "./nodes/sticker-node"; -import { GraphicNode } from "./nodes/graphic-node"; -import { ColorNode } from "./nodes/color-node"; -import { BlurBackgroundNode } from "./nodes/blur-background-node"; -import { EffectLayerNode } from "./nodes/effect-layer-node"; -import type { BaseNode } from "./nodes/base-node"; -import type { TBackground, TCanvasSize } from "@/lib/project/types"; -import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; -import { isMainTrack } from "@/lib/timeline"; - -const PREVIEW_MAX_IMAGE_SIZE = 2048; - -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); - }); -} - -function buildTrackNodes({ - tracks, - mediaMap, - canvasSize, - isPreview, -}: { - tracks: TimelineTrack[]; - mediaMap: Map; - canvasSize: TCanvasSize; - isPreview?: boolean; -}): BaseNode[] { - const nodes: BaseNode[] = []; - - 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) { - continue; - } - - if (element.type === "video" && mediaAsset.type === "video") { - nodes.push( - new VideoNode({ - mediaId: mediaAsset.id, - url: mediaAsset.url, - file: mediaAsset.file, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - retime: element.retime, - transform: element.transform, - animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, - effects: element.effects, - masks: element.masks, - }), - ); - } - if (element.type === "image" && mediaAsset.type === "image") { - nodes.push( - new ImageNode({ - url: mediaAsset.url, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, - effects: element.effects, - masks: element.masks, - ...(isPreview && { - maxSourceSize: PREVIEW_MAX_IMAGE_SIZE, - }), - }), - ); - } - } - - if (element.type === "text") { - 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") { - nodes.push( - new StickerNode({ - stickerId: element.stickerId, - intrinsicWidth: element.intrinsicWidth, - intrinsicHeight: element.intrinsicHeight, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, - effects: element.effects, - }), - ); - } - - if (element.type === "graphic") { - nodes.push( - new GraphicNode({ - definitionId: element.definitionId, - params: element.params, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, - effects: element.effects, - masks: element.masks, - }), - ); - } - } - } - - return nodes; -} - -function buildBlurBackgroundNodes({ - track, - mediaMap, - blurIntensity, -}: { - track: TimelineTrack | undefined; - mediaMap: Map; - blurIntensity: number; -}): BaseNode[] { - if (!track) { - return []; - } - - const nodes: BaseNode[] = []; - const elements = getVisibleSortedElements({ track }); - - for (const element of elements) { - if (element.type !== "video" && element.type !== "image") { - continue; - } - - const mediaAsset = mediaMap.get(element.mediaId); - if ( - !mediaAsset?.file || - !mediaAsset?.url || - (mediaAsset.type !== "video" && mediaAsset.type !== "image") - ) { - continue; - } - - nodes.push( - new BlurBackgroundNode({ - mediaId: mediaAsset.id, - url: mediaAsset.url, - file: mediaAsset.file, - mediaType: mediaAsset.type, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - retime: element.type === "video" ? element.retime : undefined, - blurIntensity, - }), - ); - } - - 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 mainTrack = orderedTracksBottomToTop.find((track) => - isMainTrack(track), - ); - - const allNodes = buildTrackNodes({ - tracks: orderedTracksBottomToTop, - mediaMap, - canvasSize, - isPreview, - }); - - if (background.type === "blur") { - const blurNodes = buildBlurBackgroundNodes({ - track: mainTrack, - mediaMap, - blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, - }); - for (const node of blurNodes) { - 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; -} +import type { TimelineTrack } from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; +import { RootNode } from "./nodes/root-node"; +import { VideoNode } from "./nodes/video-node"; +import { ImageNode } from "./nodes/image-node"; +import { TextNode } from "./nodes/text-node"; +import { StickerNode } from "./nodes/sticker-node"; +import { GraphicNode } from "./nodes/graphic-node"; +import { ColorNode } from "./nodes/color-node"; +import { BlurBackgroundNode } from "./nodes/blur-background-node"; +import { EffectLayerNode } from "./nodes/effect-layer-node"; +import type { BaseNode } from "./nodes/base-node"; +import type { TBackground, TCanvasSize } from "@/lib/project/types"; +import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; +import { isMainTrack } from "@/lib/timeline"; + +const PREVIEW_MAX_IMAGE_SIZE = 2048; + +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); + }); +} + +function buildTrackNodes({ + tracks, + mediaMap, + canvasSize, + isPreview, +}: { + tracks: TimelineTrack[]; + mediaMap: Map; + canvasSize: TCanvasSize; + isPreview?: boolean; +}): BaseNode[] { + const nodes: BaseNode[] = []; + + 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) { + continue; + } + + if (element.type === "video" && mediaAsset.type === "video") { + nodes.push( + new VideoNode({ + mediaId: mediaAsset.id, + url: mediaAsset.url, + file: mediaAsset.file, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + retime: element.retime, + transform: element.transform, + animations: element.animations, + opacity: element.opacity, + blendMode: element.blendMode, + effects: element.effects, + masks: element.masks, + }), + ); + } + if (element.type === "image" && mediaAsset.type === "image") { + nodes.push( + new ImageNode({ + url: mediaAsset.url, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + transform: element.transform, + animations: element.animations, + opacity: element.opacity, + blendMode: element.blendMode, + effects: element.effects, + masks: element.masks, + ...(isPreview && { + maxSourceSize: PREVIEW_MAX_IMAGE_SIZE, + }), + }), + ); + } + } + + if (element.type === "text") { + 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") { + nodes.push( + new StickerNode({ + stickerId: element.stickerId, + intrinsicWidth: element.intrinsicWidth, + intrinsicHeight: element.intrinsicHeight, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + transform: element.transform, + animations: element.animations, + opacity: element.opacity, + blendMode: element.blendMode, + effects: element.effects, + }), + ); + } + + if (element.type === "graphic") { + nodes.push( + new GraphicNode({ + definitionId: element.definitionId, + params: element.params, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + transform: element.transform, + animations: element.animations, + opacity: element.opacity, + blendMode: element.blendMode, + effects: element.effects, + masks: element.masks, + }), + ); + } + } + } + + return nodes; +} + +function buildBlurBackgroundNodes({ + track, + mediaMap, + blurIntensity, +}: { + track: TimelineTrack | undefined; + mediaMap: Map; + blurIntensity: number; +}): BaseNode[] { + if (!track) { + return []; + } + + const nodes: BaseNode[] = []; + const elements = getVisibleSortedElements({ track }); + + for (const element of elements) { + if (element.type !== "video" && element.type !== "image") { + continue; + } + + const mediaAsset = mediaMap.get(element.mediaId); + if ( + !mediaAsset?.file || + !mediaAsset?.url || + (mediaAsset.type !== "video" && mediaAsset.type !== "image") + ) { + continue; + } + + nodes.push( + new BlurBackgroundNode({ + mediaId: mediaAsset.id, + url: mediaAsset.url, + file: mediaAsset.file, + mediaType: mediaAsset.type, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + retime: element.type === "video" ? element.retime : undefined, + blurIntensity, + }), + ); + } + + 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 mainTrack = orderedTracksBottomToTop.find((track) => + isMainTrack(track), + ); + + const allNodes = buildTrackNodes({ + tracks: orderedTracksBottomToTop, + mediaMap, + canvasSize, + isPreview, + }); + + if (background.type === "blur") { + const blurNodes = buildBlurBackgroundNodes({ + track: mainTrack, + mediaMap, + blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, + }); + for (const node of blurNodes) { + 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 6ab8c6f3..2dbb7cdd 100644 --- a/apps/web/src/services/renderer/scene-exporter.ts +++ b/apps/web/src/services/renderer/scene-exporter.ts @@ -1,163 +1,163 @@ -import EventEmitter from "eventemitter3"; - -import { - Output, - Mp4OutputFormat, - WebMOutputFormat, - BufferTarget, - CanvasSource, - AudioBufferSource, - QUALITY_LOW, - QUALITY_MEDIUM, - QUALITY_HIGH, - QUALITY_VERY_HIGH, -} from "mediabunny"; -import type { RootNode } from "./nodes/root-node"; -import type { ExportFormat, ExportQuality } from "@/lib/export"; -import { CanvasRenderer } from "./canvas-renderer"; - -type ExportParams = { - width: number; - height: number; - fps: number; - format: ExportFormat; - quality: ExportQuality; - shouldIncludeAudio?: boolean; - audioBuffer?: AudioBuffer; -}; - -const qualityMap = { - low: QUALITY_LOW, - medium: QUALITY_MEDIUM, - high: QUALITY_HIGH, - very_high: QUALITY_VERY_HIGH, -}; - -export type SceneExporterEvents = { - progress: [progress: number]; - complete: [buffer: ArrayBuffer]; - error: [error: Error]; - cancelled: []; -}; - -export class SceneExporter extends EventEmitter { - private renderer: CanvasRenderer; - private format: ExportFormat; - private quality: ExportQuality; - private shouldIncludeAudio: boolean; - private audioBuffer?: AudioBuffer; - - private isCancelled = false; - - constructor({ - width, - height, - fps, - format, - quality, - shouldIncludeAudio, - audioBuffer, - }: ExportParams) { - super(); - this.renderer = new CanvasRenderer({ - width, - height, - fps, - }); - - this.format = format; - this.quality = quality; - this.shouldIncludeAudio = shouldIncludeAudio ?? false; - this.audioBuffer = audioBuffer; - } - - cancel(): void { - this.isCancelled = true; - } - - async export({ - rootNode, - }: { - rootNode: RootNode; - }): Promise { - const { fps } = this.renderer; - const frameCount = Math.ceil(rootNode.duration * fps); - - const outputFormat = - this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); - - const output = new Output({ - format: outputFormat, - target: new BufferTarget(), - }); - - const videoSource = new CanvasSource(this.renderer.canvas, { - codec: this.format === "webm" ? "vp9" : "avc", - bitrate: qualityMap[this.quality], - }); - - output.addVideoTrack(videoSource, { frameRate: fps }); - - let audioSource: AudioBufferSource | null = null; - if (this.shouldIncludeAudio && this.audioBuffer) { - let audioCodec: "aac" | "opus" = - this.format === "webm" ? "opus" : "aac"; - - if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") { - const { supported } = await AudioEncoder.isConfigSupported({ - codec: "mp4a.40.2", - sampleRate: this.audioBuffer.sampleRate, - numberOfChannels: this.audioBuffer.numberOfChannels, - bitrate: 192000, - }); - if (!supported) audioCodec = "opus"; - } - - audioSource = new AudioBufferSource({ - codec: audioCodec, - bitrate: qualityMap[this.quality], - }); - output.addAudioTrack(audioSource); - } - - await output.start(); - - if (audioSource && this.audioBuffer) { - await audioSource.add(this.audioBuffer); - audioSource.close(); - } - - for (let i = 0; i < frameCount; i++) { - if (this.isCancelled) { - await output.cancel(); - this.emit("cancelled"); - return null; - } - - const time = i / fps; - await this.renderer.render({ node: rootNode, time }); - await videoSource.add(time, 1 / fps); - - this.emit("progress", i / frameCount); - } - - if (this.isCancelled) { - await output.cancel(); - this.emit("cancelled"); - return null; - } - - videoSource.close(); - await output.finalize(); - this.emit("progress", 1); - - const buffer = output.target.buffer; - if (!buffer) { - this.emit("error", new Error("Failed to export video")); - return null; - } - - this.emit("complete", buffer); - return buffer; - } -} +import EventEmitter from "eventemitter3"; + +import { + Output, + Mp4OutputFormat, + WebMOutputFormat, + BufferTarget, + CanvasSource, + AudioBufferSource, + QUALITY_LOW, + QUALITY_MEDIUM, + QUALITY_HIGH, + QUALITY_VERY_HIGH, +} from "mediabunny"; +import type { RootNode } from "./nodes/root-node"; +import type { ExportFormat, ExportQuality } from "@/lib/export"; +import { CanvasRenderer } from "./canvas-renderer"; + +type ExportParams = { + width: number; + height: number; + fps: number; + format: ExportFormat; + quality: ExportQuality; + shouldIncludeAudio?: boolean; + audioBuffer?: AudioBuffer; +}; + +const qualityMap = { + low: QUALITY_LOW, + medium: QUALITY_MEDIUM, + high: QUALITY_HIGH, + very_high: QUALITY_VERY_HIGH, +}; + +export type SceneExporterEvents = { + progress: [progress: number]; + complete: [buffer: ArrayBuffer]; + error: [error: Error]; + cancelled: []; +}; + +export class SceneExporter extends EventEmitter { + private renderer: CanvasRenderer; + private format: ExportFormat; + private quality: ExportQuality; + private shouldIncludeAudio: boolean; + private audioBuffer?: AudioBuffer; + + private isCancelled = false; + + constructor({ + width, + height, + fps, + format, + quality, + shouldIncludeAudio, + audioBuffer, + }: ExportParams) { + super(); + this.renderer = new CanvasRenderer({ + width, + height, + fps, + }); + + this.format = format; + this.quality = quality; + this.shouldIncludeAudio = shouldIncludeAudio ?? false; + this.audioBuffer = audioBuffer; + } + + cancel(): void { + this.isCancelled = true; + } + + async export({ + rootNode, + }: { + rootNode: RootNode; + }): Promise { + const { fps } = this.renderer; + const frameCount = Math.ceil(rootNode.duration * fps); + + const outputFormat = + this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); + + const output = new Output({ + format: outputFormat, + target: new BufferTarget(), + }); + + const videoSource = new CanvasSource(this.renderer.canvas, { + codec: this.format === "webm" ? "vp9" : "avc", + bitrate: qualityMap[this.quality], + }); + + output.addVideoTrack(videoSource, { frameRate: fps }); + + let audioSource: AudioBufferSource | null = null; + if (this.shouldIncludeAudio && this.audioBuffer) { + let audioCodec: "aac" | "opus" = + this.format === "webm" ? "opus" : "aac"; + + if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") { + const { supported } = await AudioEncoder.isConfigSupported({ + codec: "mp4a.40.2", + sampleRate: this.audioBuffer.sampleRate, + numberOfChannels: this.audioBuffer.numberOfChannels, + bitrate: 192000, + }); + if (!supported) audioCodec = "opus"; + } + + audioSource = new AudioBufferSource({ + codec: audioCodec, + bitrate: qualityMap[this.quality], + }); + output.addAudioTrack(audioSource); + } + + await output.start(); + + if (audioSource && this.audioBuffer) { + await audioSource.add(this.audioBuffer); + audioSource.close(); + } + + for (let i = 0; i < frameCount; i++) { + if (this.isCancelled) { + await output.cancel(); + this.emit("cancelled"); + return null; + } + + const time = i / fps; + await this.renderer.render({ node: rootNode, time }); + await videoSource.add(time, 1 / fps); + + this.emit("progress", i / frameCount); + } + + if (this.isCancelled) { + await output.cancel(); + this.emit("cancelled"); + return null; + } + + videoSource.close(); + await output.finalize(); + this.emit("progress", 1); + + const buffer = output.target.buffer; + if (!buffer) { + this.emit("error", new Error("Failed to export video")); + return null; + } + + this.emit("complete", buffer); + return buffer; + } +} diff --git a/apps/web/src/services/renderer/webgl/jfa.ts b/apps/web/src/services/renderer/webgl/jfa.ts index b69a9d06..9c87e7fa 100644 --- a/apps/web/src/services/renderer/webgl/jfa.ts +++ b/apps/web/src/services/renderer/webgl/jfa.ts @@ -1,194 +1,194 @@ -import jfaInitShader from "@/lib/shaders/jfa-init.frag.glsl"; -import jfaStepShader from "@/lib/shaders/jfa-step.frag.glsl"; -import { - compileProgram, - createFramebufferTexture, - setUniforms, - drawFullscreenQuad, -} from "./webgl-utils"; - -interface FBPair { - texture: WebGLTexture; - framebuffer: WebGLFramebuffer; -} - -function runPass({ - context, - program, - inputTexture, - target, - width, - height, - uniforms, - extraBindings, -}: { - context: WebGLRenderingContext; - program: WebGLProgram; - inputTexture: WebGLTexture; - target: WebGLFramebuffer | null; - width: number; - height: number; - uniforms: Record; - extraBindings?: Array<{ unit: number; texture: WebGLTexture; name: string }>; -}): void { - context.bindFramebuffer(context.FRAMEBUFFER, target); - // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook - context.useProgram(program); - - context.activeTexture(context.TEXTURE0); - context.bindTexture(context.TEXTURE_2D, inputTexture); - const uTexLoc = context.getUniformLocation(program, "u_texture"); - if (uTexLoc) context.uniform1i(uTexLoc, 0); - - if (extraBindings) { - for (const binding of extraBindings) { - context.activeTexture(context.TEXTURE0 + binding.unit); - context.bindTexture(context.TEXTURE_2D, binding.texture); - const loc = context.getUniformLocation(program, binding.name); - if (loc) context.uniform1i(loc, binding.unit); - } - } - - setUniforms({ - context, - program, - uniforms: { ...uniforms, u_resolution: [width, height] }, - }); - drawFullscreenQuad({ context, program, width, height }); -} - -function runJFA({ - context, - programCache, - sourceTexture, - width, - height, - isInverted, -}: { - context: WebGLRenderingContext; - programCache: Map; - sourceTexture: WebGLTexture; - width: number; - height: number; - isInverted: boolean; -}): { - resultTexture: WebGLTexture; - resultFB: WebGLFramebuffer; - tempFBs: FBPair[]; -} { - const numSteps = Math.ceil(Math.log2(Math.max(width, height))); - - const fbA = createFramebufferTexture({ context, width, height }); - const fbB = createFramebufferTexture({ context, width, height }); - - const initProgram = compileProgram({ - context, - fragmentShaderSource: jfaInitShader, - programCache, - }); - runPass({ - context, - program: initProgram, - inputTexture: sourceTexture, - target: fbA.framebuffer, - width, - height, - uniforms: { u_invert: isInverted ? 1.0 : 0.0 }, - }); - - const stepProgram = compileProgram({ - context, - fragmentShaderSource: jfaStepShader, - programCache, - }); - - let readFB = fbA; - let writeFB = fbB; - - for (let i = numSteps - 1; i >= 0; i--) { - const stepSize = 2 ** i; - runPass({ - context, - program: stepProgram, - inputTexture: readFB.texture, - target: writeFB.framebuffer, - width, - height, - uniforms: { u_step_size: stepSize }, - }); - const tmp = readFB; - readFB = writeFB; - writeFB = tmp; - } - - return { - resultTexture: readFB.texture, - resultFB: readFB.framebuffer, - tempFBs: [writeFB], - }; -} - -function cleanupJFAResult({ - context, - result, -}: { - context: WebGLRenderingContext; - result: { - resultTexture: WebGLTexture; - resultFB: WebGLFramebuffer; - tempFBs: FBPair[]; - }; -}): void { - context.deleteTexture(result.resultTexture); - context.deleteFramebuffer(result.resultFB); - for (const fb of result.tempFBs) { - context.deleteTexture(fb.texture); - context.deleteFramebuffer(fb.framebuffer); - } -} - -export { runPass }; - -export function computeSignedDistanceField({ - context, - programCache, - sourceTexture, - width, - height, -}: { - context: WebGLRenderingContext; - programCache: Map; - sourceTexture: WebGLTexture; - width: number; - height: number; -}): { - insideTexture: WebGLTexture; - outsideTexture: WebGLTexture; - cleanup: () => void; -} { - const inside = runJFA({ - context, - programCache, - sourceTexture, - width, - height, - isInverted: false, - }); - const outside = runJFA({ - context, - programCache, - sourceTexture, - width, - height, - isInverted: true, - }); - - return { - insideTexture: inside.resultTexture, - outsideTexture: outside.resultTexture, - cleanup: () => { - cleanupJFAResult({ context, result: inside }); - cleanupJFAResult({ context, result: outside }); - }, - }; -} +import jfaInitShader from "@/lib/shaders/jfa-init.frag.glsl"; +import jfaStepShader from "@/lib/shaders/jfa-step.frag.glsl"; +import { + compileProgram, + createFramebufferTexture, + setUniforms, + drawFullscreenQuad, +} from "./webgl-utils"; + +interface FBPair { + texture: WebGLTexture; + framebuffer: WebGLFramebuffer; +} + +function runPass({ + context, + program, + inputTexture, + target, + width, + height, + uniforms, + extraBindings, +}: { + context: WebGLRenderingContext; + program: WebGLProgram; + inputTexture: WebGLTexture; + target: WebGLFramebuffer | null; + width: number; + height: number; + uniforms: Record; + extraBindings?: Array<{ unit: number; texture: WebGLTexture; name: string }>; +}): void { + context.bindFramebuffer(context.FRAMEBUFFER, target); + // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook + context.useProgram(program); + + context.activeTexture(context.TEXTURE0); + context.bindTexture(context.TEXTURE_2D, inputTexture); + const uTexLoc = context.getUniformLocation(program, "u_texture"); + if (uTexLoc) context.uniform1i(uTexLoc, 0); + + if (extraBindings) { + for (const binding of extraBindings) { + context.activeTexture(context.TEXTURE0 + binding.unit); + context.bindTexture(context.TEXTURE_2D, binding.texture); + const loc = context.getUniformLocation(program, binding.name); + if (loc) context.uniform1i(loc, binding.unit); + } + } + + setUniforms({ + context, + program, + uniforms: { ...uniforms, u_resolution: [width, height] }, + }); + drawFullscreenQuad({ context, program, width, height }); +} + +function runJFA({ + context, + programCache, + sourceTexture, + width, + height, + isInverted, +}: { + context: WebGLRenderingContext; + programCache: Map; + sourceTexture: WebGLTexture; + width: number; + height: number; + isInverted: boolean; +}): { + resultTexture: WebGLTexture; + resultFB: WebGLFramebuffer; + tempFBs: FBPair[]; +} { + const numSteps = Math.ceil(Math.log2(Math.max(width, height))); + + const fbA = createFramebufferTexture({ context, width, height }); + const fbB = createFramebufferTexture({ context, width, height }); + + const initProgram = compileProgram({ + context, + fragmentShaderSource: jfaInitShader, + programCache, + }); + runPass({ + context, + program: initProgram, + inputTexture: sourceTexture, + target: fbA.framebuffer, + width, + height, + uniforms: { u_invert: isInverted ? 1.0 : 0.0 }, + }); + + const stepProgram = compileProgram({ + context, + fragmentShaderSource: jfaStepShader, + programCache, + }); + + let readFB = fbA; + let writeFB = fbB; + + for (let i = numSteps - 1; i >= 0; i--) { + const stepSize = 2 ** i; + runPass({ + context, + program: stepProgram, + inputTexture: readFB.texture, + target: writeFB.framebuffer, + width, + height, + uniforms: { u_step_size: stepSize }, + }); + const tmp = readFB; + readFB = writeFB; + writeFB = tmp; + } + + return { + resultTexture: readFB.texture, + resultFB: readFB.framebuffer, + tempFBs: [writeFB], + }; +} + +function cleanupJFAResult({ + context, + result, +}: { + context: WebGLRenderingContext; + result: { + resultTexture: WebGLTexture; + resultFB: WebGLFramebuffer; + tempFBs: FBPair[]; + }; +}): void { + context.deleteTexture(result.resultTexture); + context.deleteFramebuffer(result.resultFB); + for (const fb of result.tempFBs) { + context.deleteTexture(fb.texture); + context.deleteFramebuffer(fb.framebuffer); + } +} + +export { runPass }; + +export function computeSignedDistanceField({ + context, + programCache, + sourceTexture, + width, + height, +}: { + context: WebGLRenderingContext; + programCache: Map; + sourceTexture: WebGLTexture; + width: number; + height: number; +}): { + insideTexture: WebGLTexture; + outsideTexture: WebGLTexture; + cleanup: () => void; +} { + const inside = runJFA({ + context, + programCache, + sourceTexture, + width, + height, + isInverted: false, + }); + const outside = runJFA({ + context, + programCache, + sourceTexture, + width, + height, + isInverted: true, + }); + + return { + insideTexture: inside.resultTexture, + outsideTexture: outside.resultTexture, + cleanup: () => { + cleanupJFAResult({ context, result: inside }); + cleanupJFAResult({ context, result: outside }); + }, + }; +} diff --git a/apps/web/src/services/renderer/webgl/webgl-context.ts b/apps/web/src/services/renderer/webgl/webgl-context.ts index aeefc503..39869a02 100644 --- a/apps/web/src/services/renderer/webgl/webgl-context.ts +++ b/apps/web/src/services/renderer/webgl/webgl-context.ts @@ -1,49 +1,49 @@ -import { createOffscreenCanvas } from "../canvas-utils"; - -let gl: WebGLRenderingContext | null = null; -let webglCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; -const programCache = new Map(); - -export function getWebGLContext({ - width, - height, -}: { - width: number; - height: number; -}): { - context: WebGLRenderingContext; - programCache: Map; -} { - if (!webglCanvas) { - webglCanvas = createOffscreenCanvas({ width, height }); - gl = webglCanvas.getContext("webgl", { - premultipliedAlpha: false, - }) as WebGLRenderingContext | null; - if (!gl) throw new Error("WebGL not supported"); - } - if (webglCanvas.width !== width || webglCanvas.height !== height) { - webglCanvas.width = width; - webglCanvas.height = height; - } - if (!gl) throw new Error("WebGL context lost"); - return { context: gl, programCache }; -} - -export function readResult({ - width, - height, -}: { - width: number; - height: number; -}): OffscreenCanvas | HTMLCanvasElement { - if (!webglCanvas) throw new Error("WebGL canvas not initialized"); - const outputCanvas = createOffscreenCanvas({ width, height }); - const outputCtx = outputCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (outputCtx) { - outputCtx.drawImage(webglCanvas, 0, 0, width, height); - } - return outputCanvas; -} +import { createOffscreenCanvas } from "../canvas-utils"; + +let gl: WebGLRenderingContext | null = null; +let webglCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; +const programCache = new Map(); + +export function getWebGLContext({ + width, + height, +}: { + width: number; + height: number; +}): { + context: WebGLRenderingContext; + programCache: Map; +} { + if (!webglCanvas) { + webglCanvas = createOffscreenCanvas({ width, height }); + gl = webglCanvas.getContext("webgl", { + premultipliedAlpha: false, + }) as WebGLRenderingContext | null; + if (!gl) throw new Error("WebGL not supported"); + } + if (webglCanvas.width !== width || webglCanvas.height !== height) { + webglCanvas.width = width; + webglCanvas.height = height; + } + if (!gl) throw new Error("WebGL context lost"); + return { context: gl, programCache }; +} + +export function readResult({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement { + if (!webglCanvas) throw new Error("WebGL canvas not initialized"); + const outputCanvas = createOffscreenCanvas({ width, height }); + const outputCtx = outputCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (outputCtx) { + outputCtx.drawImage(webglCanvas, 0, 0, width, height); + } + return outputCanvas; +} diff --git a/apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts b/apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts index 89dcb2ae..95ef990b 100644 --- a/apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts +++ b/apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts @@ -1,35 +1,35 @@ -import { getWebGLContext, readResult } from "./webgl-context"; -import { applyMultiPassEffect } from "./webgl-utils"; -import type { EffectPassData } from "./webgl-utils"; - -export interface ApplyEffectParams { - source: CanvasImageSource; - width: number; - height: number; - passes: EffectPassData[]; -} - -function applyEffect({ - source, - width, - height, - passes, -}: ApplyEffectParams): CanvasImageSource { - if (passes.length === 0) { - return source; - } - const { context, programCache } = getWebGLContext({ width, height }); - applyMultiPassEffect({ - context, - source, - width, - height, - passes, - programCache, - }); - return readResult({ width, height }); -} - -export const webglEffectRenderer = { - applyEffect, -}; +import { getWebGLContext, readResult } from "./webgl-context"; +import { applyMultiPassEffect } from "./webgl-utils"; +import type { EffectPassData } from "./webgl-utils"; + +export interface ApplyEffectParams { + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; +} + +function applyEffect({ + source, + width, + height, + passes, +}: ApplyEffectParams): CanvasImageSource { + if (passes.length === 0) { + return source; + } + const { context, programCache } = getWebGLContext({ width, height }); + applyMultiPassEffect({ + context, + source, + width, + height, + passes, + programCache, + }); + return readResult({ width, height }); +} + +export const webglEffectRenderer = { + applyEffect, +}; diff --git a/apps/web/src/services/renderer/webgl/webgl-utils.ts b/apps/web/src/services/renderer/webgl/webgl-utils.ts index 834c652e..ec207546 100644 --- a/apps/web/src/services/renderer/webgl/webgl-utils.ts +++ b/apps/web/src/services/renderer/webgl/webgl-utils.ts @@ -1,298 +1,298 @@ -import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl"; - -export interface EffectPassData { - fragmentShader: string; - uniforms: Record; -} - -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; -}): 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; -}): 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; -}): 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); -} +import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl"; + +export interface EffectPassData { + fragmentShader: string; + uniforms: Record; +} + +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; +}): 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; +}): 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; +}): 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/__tests__/fixtures/index.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts index 5f82e14a..a97e42d7 100644 --- a/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts +++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts @@ -1,10 +1,10 @@ -/** - * Test fixtures for storage migrations. - * These represent real project data shapes from each version. - */ - -export * from "./v0"; -export * from "./v1"; -export * from "./v2"; -export * from "./v3"; -export * from "./v5"; +/** + * Test fixtures for storage migrations. + * These represent real project data shapes from each version. + */ + +export * from "./v0"; +export * from "./v1"; +export * from "./v2"; +export * from "./v3"; +export * from "./v5"; diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts index 5e181c76..89bb83c3 100644 --- a/apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts +++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts @@ -1,54 +1,54 @@ -export const v5Project = { - id: "project-v5-456", - version: 5, - metadata: { - id: "project-v5-456", - name: "My V5 Project", - thumbnail: "data:image/png;base64,abc123", - duration: 30, - createdAt: "2024-06-01T10:00:00.000Z", - updatedAt: "2024-06-01T14:00:00.000Z", - }, - settings: { - fps: 30, - canvasSize: { width: 1920, height: 1080 }, - background: { type: "color", color: "#000000" }, - }, - currentSceneId: "scene-main", - scenes: [ - { - id: "scene-main", - name: "Main scene", - isMain: true, - tracks: [ - { - id: "track-1", - type: "video", - name: "Video Track", - isMain: true, - elements: [], - }, - ], - bookmarks: [2.0, 5.5, 12.0], - createdAt: "2024-06-01T10:00:00.000Z", - updatedAt: "2024-06-01T14:00:00.000Z", - }, - { - id: "scene-intro", - name: "Intro", - isMain: false, - tracks: [ - { - id: "track-2", - type: "video", - name: "Video Track", - isMain: true, - elements: [], - }, - ], - bookmarks: [], - createdAt: "2024-06-01T10:00:00.000Z", - updatedAt: "2024-06-01T14:00:00.000Z", - }, - ], -}; +export const v5Project = { + id: "project-v5-456", + version: 5, + metadata: { + id: "project-v5-456", + name: "My V5 Project", + thumbnail: "data:image/png;base64,abc123", + duration: 30, + createdAt: "2024-06-01T10:00:00.000Z", + updatedAt: "2024-06-01T14:00:00.000Z", + }, + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + currentSceneId: "scene-main", + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-1", + type: "video", + name: "Video Track", + isMain: true, + elements: [], + }, + ], + bookmarks: [2.0, 5.5, 12.0], + createdAt: "2024-06-01T10:00:00.000Z", + updatedAt: "2024-06-01T14:00:00.000Z", + }, + { + id: "scene-intro", + name: "Intro", + isMain: false, + tracks: [ + { + id: "track-2", + type: "video", + name: "Video Track", + isMain: true, + elements: [], + }, + ], + bookmarks: [], + createdAt: "2024-06-01T10:00:00.000Z", + updatedAt: "2024-06-01T14:00:00.000Z", + }, + ], +}; diff --git a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts index f2a4908f..122af2cb 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts @@ -1,93 +1,93 @@ -import { describe, expect, test } from "bun:test"; -import { transformProjectV15ToV16 } from "../transformers/v15-to-v16"; - -describe("V15 to V16 Migration", () => { - test("renames sticker tracks to graphic tracks", () => { - const result = transformProjectV15ToV16({ - project: { - id: "project-v15", - version: 15, - metadata: { - id: "project-v15", - name: "Project", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - settings: { - fps: 30, - canvasSize: { width: 1920, height: 1080 }, - background: { type: "color", color: "#000000" }, - }, - currentSceneId: "scene-main", - scenes: [ - { - id: "scene-main", - name: "Main scene", - isMain: true, - tracks: [ - { - id: "track-graphic", - type: "sticker", - name: "Sticker Track", - hidden: false, - elements: [ - { - id: "sticker-1", - type: "sticker", - name: "Logo", - stickerId: "icons:mdi:home", - duration: 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - transform: { - scaleX: 1, - scaleY: 1, - position: { x: 0, y: 0 }, - rotate: 0, - }, - opacity: 1, - }, - ], - }, - ], - bookmarks: [], - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ], - }, - }); - - expect(result.skipped).toBe(false); - expect(result.project.version).toBe(16); - expect( - (result.project.scenes as Array<{ tracks: Array<{ type: string }> }>)[0] - .tracks[0].type, - ).toBe("graphic"); - }); - - test("skips projects already on v16", () => { - const result = transformProjectV15ToV16({ - project: { - id: "project-v16", - version: 16, - }, - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("already v16"); - }); - - test("skips projects with no id", () => { - const result = transformProjectV15ToV16({ - project: { - version: 15, - scenes: [], - }, - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("no project id"); - }); -}); +import { describe, expect, test } from "bun:test"; +import { transformProjectV15ToV16 } from "../transformers/v15-to-v16"; + +describe("V15 to V16 Migration", () => { + test("renames sticker tracks to graphic tracks", () => { + const result = transformProjectV15ToV16({ + project: { + id: "project-v15", + version: 15, + metadata: { + id: "project-v15", + name: "Project", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + currentSceneId: "scene-main", + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-graphic", + type: "sticker", + name: "Sticker Track", + hidden: false, + elements: [ + { + id: "sticker-1", + type: "sticker", + name: "Logo", + stickerId: "icons:mdi:home", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + bookmarks: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(16); + expect( + (result.project.scenes as Array<{ tracks: Array<{ type: string }> }>)[0] + .tracks[0].type, + ).toBe("graphic"); + }); + + test("skips projects already on v16", () => { + const result = transformProjectV15ToV16({ + project: { + id: "project-v16", + version: 16, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v16"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV15ToV16({ + project: { + version: 15, + scenes: [], + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts index e246943e..352ca762 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts @@ -1,132 +1,132 @@ -import { describe, expect, test } from "bun:test"; -import { transformProjectV16ToV17 } from "../transformers/v16-to-v17"; - -describe("V16 to V17 Migration", () => { - test("adds center stroke alignment to masks that do not have it", () => { - const result = transformProjectV16ToV17({ - project: { - id: "project-v16", - version: 16, - metadata: { - id: "project-v16", - name: "Project", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - settings: { - fps: 30, - canvasSize: { width: 1920, height: 1080 }, - background: { type: "color", color: "#000000" }, - }, - currentSceneId: "scene-main", - scenes: [ - { - id: "scene-main", - name: "Main scene", - isMain: true, - tracks: [ - { - id: "track-video", - type: "video", - name: "Video Track", - hidden: false, - elements: [ - { - id: "video-1", - type: "video", - name: "Clip", - mediaId: "media-1", - duration: 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - transform: { - scaleX: 1, - scaleY: 1, - position: { x: 0, y: 0 }, - rotate: 0, - }, - opacity: 1, - masks: [ - { - id: "mask-1", - type: "rectangle", - params: { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 8, - centerX: 0, - centerY: 0, - width: 0.6, - height: 0.6, - rotation: 0, - scale: 1, - }, - }, - { - id: "mask-2", - type: "ellipse", - params: { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 8, - strokeAlign: "outside", - centerX: 0, - centerY: 0, - width: 0.6, - height: 0.6, - rotation: 0, - scale: 1, - }, - }, - ], - }, - ], - }, - ], - bookmarks: [], - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ], - }, - }); - - expect(result.skipped).toBe(false); - expect(result.project.version).toBe(17); - - const migratedMasks = ( - ((result.project.scenes as Array<{ tracks: Array<{ elements: Array<{ masks: Array<{ params: Record }> }> }> }>)[0] - .tracks[0].elements[0].masks) - ); - - expect(migratedMasks[0].params.strokeAlign).toBe("center"); - expect(migratedMasks[1].params.strokeAlign).toBe("outside"); - }); - - test("skips projects already on v17", () => { - const result = transformProjectV16ToV17({ - project: { - id: "project-v17", - version: 17, - }, - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("already v17"); - }); - - test("skips projects with no id", () => { - const result = transformProjectV16ToV17({ - project: { - version: 16, - scenes: [], - }, - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("no project id"); - }); -}); +import { describe, expect, test } from "bun:test"; +import { transformProjectV16ToV17 } from "../transformers/v16-to-v17"; + +describe("V16 to V17 Migration", () => { + test("adds center stroke alignment to masks that do not have it", () => { + const result = transformProjectV16ToV17({ + project: { + id: "project-v16", + version: 16, + metadata: { + id: "project-v16", + name: "Project", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + currentSceneId: "scene-main", + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-video", + type: "video", + name: "Video Track", + hidden: false, + elements: [ + { + id: "video-1", + type: "video", + name: "Clip", + mediaId: "media-1", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + masks: [ + { + id: "mask-1", + type: "rectangle", + params: { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 8, + centerX: 0, + centerY: 0, + width: 0.6, + height: 0.6, + rotation: 0, + scale: 1, + }, + }, + { + id: "mask-2", + type: "ellipse", + params: { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 8, + strokeAlign: "outside", + centerX: 0, + centerY: 0, + width: 0.6, + height: 0.6, + rotation: 0, + scale: 1, + }, + }, + ], + }, + ], + }, + ], + bookmarks: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(17); + + const migratedMasks = ( + ((result.project.scenes as Array<{ tracks: Array<{ elements: Array<{ masks: Array<{ params: Record }> }> }> }>)[0] + .tracks[0].elements[0].masks) + ); + + expect(migratedMasks[0].params.strokeAlign).toBe("center"); + expect(migratedMasks[1].params.strokeAlign).toBe("outside"); + }); + + test("skips projects already on v17", () => { + const result = transformProjectV16ToV17({ + project: { + id: "project-v17", + version: 17, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v17"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV16ToV17({ + project: { + version: 16, + scenes: [], + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts index 7524a8d4..0f679b6d 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts @@ -1,63 +1,63 @@ -import { describe, expect, test } from "bun:test"; -import { transformProjectV18ToV19 } from "../transformers/v18-to-v19"; - -describe("V18 to V19 Migration", () => { - test("adds canvas size mode and empty remembered custom size defaults", () => { - const result = transformProjectV18ToV19({ - project: { - id: "project-v18-defaults", - version: 18, - metadata: { - id: "project-v18-defaults", - name: "Project", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - settings: { - fps: 30, - canvasSize: { width: 1920, height: 1080 }, - originalCanvasSize: { width: 1920, height: 1080 }, - background: { type: "color", color: "#000000" }, - }, - currentSceneId: "scene-main", - scenes: [], - }, - }); - - expect(result.skipped).toBe(false); - expect(result.project.version).toBe(19); - expect( - (result.project.settings as Record).canvasSizeMode, - ).toBe("preset"); - expect( - (result.project.settings as Record).lastCustomCanvasSize, - ).toBeNull(); - expect( - (result.project.settings as Record).originalCanvasSize, - ).toEqual({ width: 1920, height: 1080 }); - }); - - test("skips projects already on v19", () => { - const result = transformProjectV18ToV19({ - project: { - id: "project-v19", - version: 19, - }, - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("already v19"); - }); - - test("skips projects with no id", () => { - const result = transformProjectV18ToV19({ - project: { - version: 18, - scenes: [], - }, - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("no project id"); - }); -}); +import { describe, expect, test } from "bun:test"; +import { transformProjectV18ToV19 } from "../transformers/v18-to-v19"; + +describe("V18 to V19 Migration", () => { + test("adds canvas size mode and empty remembered custom size defaults", () => { + const result = transformProjectV18ToV19({ + project: { + id: "project-v18-defaults", + version: 18, + metadata: { + id: "project-v18-defaults", + name: "Project", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + originalCanvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + currentSceneId: "scene-main", + scenes: [], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(19); + expect( + (result.project.settings as Record).canvasSizeMode, + ).toBe("preset"); + expect( + (result.project.settings as Record).lastCustomCanvasSize, + ).toBeNull(); + expect( + (result.project.settings as Record).originalCanvasSize, + ).toEqual({ width: 1920, height: 1080 }); + }); + + test("skips projects already on v19", () => { + const result = transformProjectV18ToV19({ + project: { + id: "project-v19", + version: 19, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v19"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV18ToV19({ + project: { + version: 18, + scenes: [], + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts index 81d03147..6c2d6597 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts @@ -1,140 +1,140 @@ -import { describe, expect, test } from "bun:test"; -import { transformProjectV3ToV4 } from "../transformers/v3-to-v4"; -import { v3Project } from "./fixtures"; - -describe("V3 to V4 Migration", () => { - test("normalizes legacy text fontWeight values", () => { - const projectWithLegacyTextWeight = { - ...v3Project, - scenes: [ - { - ...v3Project.scenes[0], - tracks: [ - { - id: "track-text", - type: "text", - name: "Text Track", - hidden: false, - elements: [ - { - id: "text-1", - type: "text", - name: "Title", - content: "Hello", - duration: 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - fontSize: 64, - fontFamily: "Inter", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center", - fontWeight: "bold", - fontStyle: "normal", - textDecoration: "none", - transform: { - scale: 1, - position: { x: 0, y: 0 }, - rotate: 0, - }, - opacity: 1, - }, - ], - }, - ], - }, - ], - }; - - const result = transformProjectV3ToV4({ - project: projectWithLegacyTextWeight, - }); - - expect(result.skipped).toBe(false); - expect(result.project.version).toBe(4); - - const migratedScene = ( - result.project.scenes as Array> - )[0]; - const migratedTrack = ( - migratedScene.tracks as Array> - )[0]; - const migratedElement = ( - migratedTrack.elements as Array> - )[0]; - - expect(migratedElement.fontWeight).toBe("700"); - }); - - test("does not mutate non-text tracks", () => { - const projectWithoutTextTrack = { - ...v3Project, - scenes: [ - { - ...v3Project.scenes[0], - tracks: [ - { - id: "track-sticker", - type: "sticker", - name: "Sticker Track", - hidden: false, - elements: [ - { - id: "sticker-1", - type: "sticker", - name: "Flag", - iconName: "mdi:home", - duration: 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - transform: { - scale: 1, - position: { x: 0, y: 0 }, - rotate: 0, - }, - opacity: 1, - }, - ], - }, - ], - }, - ], - }; - - const result = transformProjectV3ToV4({ project: projectWithoutTextTrack }); - const migratedScene = ( - result.project.scenes as Array> - )[0]; - const migratedTrack = ( - migratedScene.tracks as Array> - )[0]; - const migratedElement = ( - migratedTrack.elements as Array> - )[0]; - - expect(migratedElement.iconName).toBe("mdi:home"); - }); - - test("skips projects that are already v4", () => { - const result = transformProjectV3ToV4({ - project: { ...v3Project, version: 4 }, - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("already v4"); - }); - - test("skips projects with no id", () => { - const result = transformProjectV3ToV4({ - project: { - version: 3, - scenes: [], - }, - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("no project id"); - }); -}); +import { describe, expect, test } from "bun:test"; +import { transformProjectV3ToV4 } from "../transformers/v3-to-v4"; +import { v3Project } from "./fixtures"; + +describe("V3 to V4 Migration", () => { + test("normalizes legacy text fontWeight values", () => { + const projectWithLegacyTextWeight = { + ...v3Project, + scenes: [ + { + ...v3Project.scenes[0], + tracks: [ + { + id: "track-text", + type: "text", + name: "Text Track", + hidden: false, + elements: [ + { + id: "text-1", + type: "text", + name: "Title", + content: "Hello", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + fontSize: 64, + fontFamily: "Inter", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center", + fontWeight: "bold", + fontStyle: "normal", + textDecoration: "none", + transform: { + scale: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + }, + ], + }; + + const result = transformProjectV3ToV4({ + project: projectWithLegacyTextWeight, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(4); + + const migratedScene = ( + result.project.scenes as Array> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array> + )[0]; + + expect(migratedElement.fontWeight).toBe("700"); + }); + + test("does not mutate non-text tracks", () => { + const projectWithoutTextTrack = { + ...v3Project, + scenes: [ + { + ...v3Project.scenes[0], + tracks: [ + { + id: "track-sticker", + type: "sticker", + name: "Sticker Track", + hidden: false, + elements: [ + { + id: "sticker-1", + type: "sticker", + name: "Flag", + iconName: "mdi:home", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: { + scale: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + }, + ], + }; + + const result = transformProjectV3ToV4({ project: projectWithoutTextTrack }); + const migratedScene = ( + result.project.scenes as Array> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array> + )[0]; + + expect(migratedElement.iconName).toBe("mdi:home"); + }); + + test("skips projects that are already v4", () => { + const result = transformProjectV3ToV4({ + project: { ...v3Project, version: 4 }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v4"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV3ToV4({ + project: { + version: 3, + scenes: [], + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts index ce0c3019..95386137 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts @@ -1,137 +1,137 @@ -import { describe, expect, test } from "bun:test"; -import { transformProjectV4ToV5 } from "../transformers/v4-to-v5"; -import { v3Project } from "./fixtures"; - -describe("V4 to V5 Migration", () => { - test("migrates sticker iconName to stickerId and removes legacy color", () => { - const projectWithLegacySticker = { - ...v3Project, - version: 4, - scenes: [ - { - ...v3Project.scenes[0], - tracks: [ - { - id: "track-sticker", - type: "sticker", - name: "Sticker Track", - hidden: false, - elements: [ - { - id: "sticker-1", - type: "sticker", - name: "Home", - iconName: "mdi:home", - color: "#ff0000", - duration: 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - transform: { - scale: 1, - position: { x: 0, y: 0 }, - rotate: 0, - }, - opacity: 1, - }, - ], - }, - ], - }, - ], - }; - - const result = transformProjectV4ToV5({ - project: projectWithLegacySticker, - }); - - expect(result.skipped).toBe(false); - expect(result.project.version).toBe(5); - - const migratedScene = ( - result.project.scenes as Array> - )[0]; - const migratedTrack = ( - migratedScene.tracks as Array> - )[0]; - const migratedElement = ( - migratedTrack.elements as Array> - )[0]; - - expect(migratedElement.stickerId).toBe("icons:mdi:home"); - expect("iconName" in migratedElement).toBe(false); - expect("color" in migratedElement).toBe(false); - }); - - test("keeps provider-prefixed stickerId values unchanged", () => { - const projectWithStickerId = { - ...v3Project, - version: 4, - scenes: [ - { - ...v3Project.scenes[0], - tracks: [ - { - id: "track-sticker", - type: "sticker", - name: "Sticker Track", - hidden: false, - elements: [ - { - id: "sticker-1", - type: "sticker", - name: "Flag", - stickerId: "flags:AD", - duration: 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - transform: { - scale: 1, - position: { x: 0, y: 0 }, - rotate: 0, - }, - opacity: 1, - }, - ], - }, - ], - }, - ], - }; - - const result = transformProjectV4ToV5({ project: projectWithStickerId }); - const migratedScene = ( - result.project.scenes as Array> - )[0]; - const migratedTrack = ( - migratedScene.tracks as Array> - )[0]; - const migratedElement = ( - migratedTrack.elements as Array> - )[0]; - - expect(migratedElement.stickerId).toBe("flags:AD"); - }); - - test("skips projects that are already v5", () => { - const result = transformProjectV4ToV5({ - project: { ...v3Project, version: 5 }, - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("already v5"); - }); - - test("skips projects with no id", () => { - const result = transformProjectV4ToV5({ - project: { - version: 4, - scenes: [], - }, - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("no project id"); - }); -}); +import { describe, expect, test } from "bun:test"; +import { transformProjectV4ToV5 } from "../transformers/v4-to-v5"; +import { v3Project } from "./fixtures"; + +describe("V4 to V5 Migration", () => { + test("migrates sticker iconName to stickerId and removes legacy color", () => { + const projectWithLegacySticker = { + ...v3Project, + version: 4, + scenes: [ + { + ...v3Project.scenes[0], + tracks: [ + { + id: "track-sticker", + type: "sticker", + name: "Sticker Track", + hidden: false, + elements: [ + { + id: "sticker-1", + type: "sticker", + name: "Home", + iconName: "mdi:home", + color: "#ff0000", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: { + scale: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + }, + ], + }; + + const result = transformProjectV4ToV5({ + project: projectWithLegacySticker, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(5); + + const migratedScene = ( + result.project.scenes as Array> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array> + )[0]; + + expect(migratedElement.stickerId).toBe("icons:mdi:home"); + expect("iconName" in migratedElement).toBe(false); + expect("color" in migratedElement).toBe(false); + }); + + test("keeps provider-prefixed stickerId values unchanged", () => { + const projectWithStickerId = { + ...v3Project, + version: 4, + scenes: [ + { + ...v3Project.scenes[0], + tracks: [ + { + id: "track-sticker", + type: "sticker", + name: "Sticker Track", + hidden: false, + elements: [ + { + id: "sticker-1", + type: "sticker", + name: "Flag", + stickerId: "flags:AD", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: { + scale: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + }, + ], + }; + + const result = transformProjectV4ToV5({ project: projectWithStickerId }); + const migratedScene = ( + result.project.scenes as Array> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array> + )[0]; + + expect(migratedElement.stickerId).toBe("flags:AD"); + }); + + test("skips projects that are already v5", () => { + const result = transformProjectV4ToV5({ + project: { ...v3Project, version: 5 }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v5"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV4ToV5({ + project: { + version: 4, + scenes: [], + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts index 6bbd5634..66e87713 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts @@ -1,92 +1,92 @@ -import { describe, expect, test } from "bun:test"; -import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; -import { v5Project } from "./fixtures"; - -describe("V5 to V6 Migration", () => { - test("converts number bookmarks to Bookmark objects", async () => { - const result = transformProjectV5ToV6({ - project: v5Project as Parameters< - typeof transformProjectV5ToV6 - >[0]["project"], - }); - - expect(result.skipped).toBe(false); - expect(result.project.version).toBe(6); - - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; - expect(mainScene.bookmarks).toEqual([ - { time: 2.0 }, - { time: 5.5 }, - { time: 12.0 }, - ]); - - const introScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[1]; - expect(introScene.bookmarks).toEqual([]); - }); - - test("skips projects that are already v6", () => { - const result = transformProjectV5ToV6({ - project: { - ...v5Project, - version: 6, - scenes: [ - { - ...(v5Project as { scenes: unknown[] }).scenes[0], - bookmarks: [{ time: 2 }, { time: 5 }], - }, - ], - } as Parameters[0]["project"], - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("already v6"); - }); - - test("skips projects with no id", () => { - const result = transformProjectV5ToV6({ - project: { - version: 5, - scenes: [], - } as Parameters[0]["project"], - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("no project id"); - }); - - test("preserves existing Bookmark objects with note, color, duration", () => { - const projectWithRichBookmarks = { - ...v5Project, - version: 5, - scenes: [ - { - ...(v5Project as { scenes: Array> }) - .scenes[0], - bookmarks: [ - { time: 1, note: "Intro", color: "#ef4444" }, - { time: 5.5, duration: 2 }, - ], - }, - ], - }; - - const result = transformProjectV5ToV6({ - project: projectWithRichBookmarks as Parameters< - typeof transformProjectV5ToV6 - >[0]["project"], - }); - - expect(result.skipped).toBe(false); - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; - expect(mainScene.bookmarks).toEqual([ - { time: 1, note: "Intro", color: "#ef4444" }, - { time: 5.5, duration: 2 }, - ]); - }); -}); +import { describe, expect, test } from "bun:test"; +import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; +import { v5Project } from "./fixtures"; + +describe("V5 to V6 Migration", () => { + test("converts number bookmarks to Bookmark objects", async () => { + const result = transformProjectV5ToV6({ + project: v5Project as Parameters< + typeof transformProjectV5ToV6 + >[0]["project"], + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(6); + + const mainScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[0]; + expect(mainScene.bookmarks).toEqual([ + { time: 2.0 }, + { time: 5.5 }, + { time: 12.0 }, + ]); + + const introScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[1]; + expect(introScene.bookmarks).toEqual([]); + }); + + test("skips projects that are already v6", () => { + const result = transformProjectV5ToV6({ + project: { + ...v5Project, + version: 6, + scenes: [ + { + ...(v5Project as { scenes: unknown[] }).scenes[0], + bookmarks: [{ time: 2 }, { time: 5 }], + }, + ], + } as Parameters[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v6"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV5ToV6({ + project: { + version: 5, + scenes: [], + } as Parameters[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); + + test("preserves existing Bookmark objects with note, color, duration", () => { + const projectWithRichBookmarks = { + ...v5Project, + version: 5, + scenes: [ + { + ...(v5Project as { scenes: Array> }) + .scenes[0], + bookmarks: [ + { time: 1, note: "Intro", color: "#ef4444" }, + { time: 5.5, duration: 2 }, + ], + }, + ], + }; + + const result = transformProjectV5ToV6({ + project: projectWithRichBookmarks as Parameters< + typeof transformProjectV5ToV6 + >[0]["project"], + }); + + expect(result.skipped).toBe(false); + const mainScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[0]; + expect(mainScene.bookmarks).toEqual([ + { time: 1, note: "Intro", color: "#ef4444" }, + { time: 5.5, duration: 2 }, + ]); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts index 51e91f78..aa79a6ea 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts @@ -1,175 +1,175 @@ -import { describe, expect, test } from "bun:test"; -import { transformProjectV8ToV9 } from "../transformers/v8-to-v9"; - -const v8ProjectWithText = { - id: "project-v8-text", - version: 8, - metadata: { - id: "project-v8-text", - name: "V8 Project with Text", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - settings: { - fps: 30, - canvasSize: { width: 1920, height: 1080 }, - background: { type: "color", color: "#000000" }, - }, - currentSceneId: "scene-main", - scenes: [ - { - id: "scene-main", - name: "Main scene", - isMain: true, - tracks: [ - { - id: "track-text", - type: "text", - name: "Text Track", - hidden: false, - elements: [ - { - id: "el-1", - type: "text", - content: "With color", - startTime: 0, - duration: 5, - background: { - color: "#ff0000", - cornerRadius: 0, - paddingX: 8, - paddingY: 4, - }, - }, - { - id: "el-2", - type: "text", - content: "Transparent", - startTime: 5, - duration: 5, - background: { - color: "transparent", - paddingX: 30, - paddingY: 42, - }, - }, - ], - }, - ], - bookmarks: [], - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ], -} as Parameters[0]["project"]; - -describe("V8 to V9 Migration", () => { - test("adds background.enabled from color (transparent => false, otherwise true)", () => { - const result = transformProjectV8ToV9({ project: v8ProjectWithText }); - - expect(result.skipped).toBe(false); - expect(result.project.version).toBe(9); - - const track = ( - result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }> - )[0].tracks[0]; - const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>; - - expect(elements[0].background.enabled).toBe(true); - expect(elements[0].background.color).toBe("#ff0000"); - - expect(elements[1].background.enabled).toBe(false); - expect(elements[1].background.color).toBe("transparent"); - }); - - test("preserves existing background.enabled if already present", () => { - const projectWithEnabled = { - ...v8ProjectWithText, - scenes: [ - { - ...(v8ProjectWithText.scenes as Record[])[0], - tracks: [ - { - id: "track-text", - type: "text", - name: "Text Track", - hidden: false, - elements: [ - { - id: "el-1", - type: "text", - content: "Already has enabled", - startTime: 0, - duration: 5, - background: { - enabled: false, - color: "#00ff00", - }, - }, - ], - }, - ], - }, - ], - } as Parameters[0]["project"]; - - const result = transformProjectV8ToV9({ project: projectWithEnabled }); - - expect(result.skipped).toBe(false); - const elements = ( - result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }> - )[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>; - expect(elements[0].background.enabled).toBe(false); - }); - - test("skips non-text elements and tracks", () => { - const projectWithVideoOnly = { - ...v8ProjectWithText, - scenes: [ - { - id: "scene-main", - name: "Main scene", - isMain: true, - tracks: [ - { - id: "track-video", - type: "video", - name: "Video Track", - isMain: true, - elements: [], - }, - ], - bookmarks: [], - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ], - } as Parameters[0]["project"]; - - const result = transformProjectV8ToV9({ project: projectWithVideoOnly }); - - expect(result.skipped).toBe(false); - expect(result.project.version).toBe(9); - }); - - test("skips projects that are already v9", () => { - const result = transformProjectV8ToV9({ - project: { ...v8ProjectWithText, version: 9 }, - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("already v9"); - }); - - test("skips projects with no id", () => { - const result = transformProjectV8ToV9({ - project: { - version: 8, - scenes: [], - } as Parameters[0]["project"], - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("no project id"); - }); -}); +import { describe, expect, test } from "bun:test"; +import { transformProjectV8ToV9 } from "../transformers/v8-to-v9"; + +const v8ProjectWithText = { + id: "project-v8-text", + version: 8, + metadata: { + id: "project-v8-text", + name: "V8 Project with Text", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + currentSceneId: "scene-main", + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-text", + type: "text", + name: "Text Track", + hidden: false, + elements: [ + { + id: "el-1", + type: "text", + content: "With color", + startTime: 0, + duration: 5, + background: { + color: "#ff0000", + cornerRadius: 0, + paddingX: 8, + paddingY: 4, + }, + }, + { + id: "el-2", + type: "text", + content: "Transparent", + startTime: 5, + duration: 5, + background: { + color: "transparent", + paddingX: 30, + paddingY: 42, + }, + }, + ], + }, + ], + bookmarks: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ], +} as Parameters[0]["project"]; + +describe("V8 to V9 Migration", () => { + test("adds background.enabled from color (transparent => false, otherwise true)", () => { + const result = transformProjectV8ToV9({ project: v8ProjectWithText }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(9); + + const track = ( + result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }> + )[0].tracks[0]; + const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>; + + expect(elements[0].background.enabled).toBe(true); + expect(elements[0].background.color).toBe("#ff0000"); + + expect(elements[1].background.enabled).toBe(false); + expect(elements[1].background.color).toBe("transparent"); + }); + + test("preserves existing background.enabled if already present", () => { + const projectWithEnabled = { + ...v8ProjectWithText, + scenes: [ + { + ...(v8ProjectWithText.scenes as Record[])[0], + tracks: [ + { + id: "track-text", + type: "text", + name: "Text Track", + hidden: false, + elements: [ + { + id: "el-1", + type: "text", + content: "Already has enabled", + startTime: 0, + duration: 5, + background: { + enabled: false, + color: "#00ff00", + }, + }, + ], + }, + ], + }, + ], + } as Parameters[0]["project"]; + + const result = transformProjectV8ToV9({ project: projectWithEnabled }); + + expect(result.skipped).toBe(false); + const elements = ( + result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }> + )[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>; + expect(elements[0].background.enabled).toBe(false); + }); + + test("skips non-text elements and tracks", () => { + const projectWithVideoOnly = { + ...v8ProjectWithText, + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-video", + type: "video", + name: "Video Track", + isMain: true, + elements: [], + }, + ], + bookmarks: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ], + } as Parameters[0]["project"]; + + const result = transformProjectV8ToV9({ project: projectWithVideoOnly }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(9); + }); + + test("skips projects that are already v9", () => { + const result = transformProjectV8ToV9({ + project: { ...v8ProjectWithText, version: 9 }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v9"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV8ToV9({ + project: { + version: 8, + scenes: [], + } as Parameters[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/base.ts b/apps/web/src/services/storage/migrations/base.ts index 5b7e9db0..13418f98 100644 --- a/apps/web/src/services/storage/migrations/base.ts +++ b/apps/web/src/services/storage/migrations/base.ts @@ -1,9 +1,9 @@ -import type { MigrationResult, ProjectRecord } from "./transformers/types"; - -export abstract class StorageMigration { - abstract from: number; - abstract to: number; - abstract transform( - project: ProjectRecord, - ): Promise>; -} +import type { MigrationResult, ProjectRecord } from "./transformers/types"; + +export abstract class StorageMigration { + abstract from: number; + abstract to: number; + abstract transform( + project: ProjectRecord, + ): Promise>; +} diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index 1a855287..e87d1cb9 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -1,46 +1,46 @@ -export { StorageMigration } from "./base"; -import { V0toV1Migration } from "./v0-to-v1"; -import { V1toV2Migration } from "./v1-to-v2"; -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"; -import { V8toV9Migration } from "./v8-to-v9"; -import { V9toV10Migration } from "./v9-to-v10"; -import { V10toV11Migration } from "./v10-to-v11"; -import { V11toV12Migration } from "./v11-to-v12"; -import { V12toV13Migration } from "./v12-to-v13"; -import { V13toV14Migration } from "./v13-to-v14"; -import { V14toV15Migration } from "./v14-to-v15"; -import { V15toV16Migration } from "./v15-to-v16"; -import { V16toV17Migration } from "./v16-to-v17"; -import { V17toV18Migration } from "./v17-to-v18"; -import { V18toV19Migration } from "./v18-to-v19"; -export { runStorageMigrations } from "./runner"; -export type { MigrationProgress } from "./runner"; - -export const CURRENT_PROJECT_VERSION = 19; - -export const migrations = [ - new V0toV1Migration(), - new V1toV2Migration(), - new V2toV3Migration(), - new V3toV4Migration(), - new V4toV5Migration(), - new V5toV6Migration(), - new V6toV7Migration(), - new V7toV8Migration(), - new V8toV9Migration(), - new V9toV10Migration(), - new V10toV11Migration(), - new V11toV12Migration(), - new V12toV13Migration(), - new V13toV14Migration(), - new V14toV15Migration(), - new V15toV16Migration(), - new V16toV17Migration(), - new V17toV18Migration(), - new V18toV19Migration(), -]; +export { StorageMigration } from "./base"; +import { V0toV1Migration } from "./v0-to-v1"; +import { V1toV2Migration } from "./v1-to-v2"; +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"; +import { V8toV9Migration } from "./v8-to-v9"; +import { V9toV10Migration } from "./v9-to-v10"; +import { V10toV11Migration } from "./v10-to-v11"; +import { V11toV12Migration } from "./v11-to-v12"; +import { V12toV13Migration } from "./v12-to-v13"; +import { V13toV14Migration } from "./v13-to-v14"; +import { V14toV15Migration } from "./v14-to-v15"; +import { V15toV16Migration } from "./v15-to-v16"; +import { V16toV17Migration } from "./v16-to-v17"; +import { V17toV18Migration } from "./v17-to-v18"; +import { V18toV19Migration } from "./v18-to-v19"; +export { runStorageMigrations } from "./runner"; +export type { MigrationProgress } from "./runner"; + +export const CURRENT_PROJECT_VERSION = 19; + +export const migrations = [ + new V0toV1Migration(), + new V1toV2Migration(), + new V2toV3Migration(), + new V3toV4Migration(), + new V4toV5Migration(), + new V5toV6Migration(), + new V6toV7Migration(), + new V7toV8Migration(), + new V8toV9Migration(), + new V9toV10Migration(), + new V10toV11Migration(), + new V11toV12Migration(), + new V12toV13Migration(), + new V13toV14Migration(), + new V14toV15Migration(), + new V15toV16Migration(), + new V16toV17Migration(), + new V17toV18Migration(), + new V18toV19Migration(), +]; diff --git a/apps/web/src/services/storage/migrations/runner.ts b/apps/web/src/services/storage/migrations/runner.ts index e1c2d09a..409a8695 100644 --- a/apps/web/src/services/storage/migrations/runner.ts +++ b/apps/web/src/services/storage/migrations/runner.ts @@ -1,155 +1,155 @@ -import { - IndexedDBAdapter, - deleteDatabase, -} from "@/services/storage/indexeddb-adapter"; -import type { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { getProjectId, isRecord } from "./transformers/utils"; - -export interface StorageMigrationResult { - migratedCount: number; -} - -export interface MigrationProgress { - isMigrating: boolean; - fromVersion: number | null; - toVersion: number | null; - projectName: string | null; -} - -let hasCleanedUpMetaDb = false; - -const MIN_MIGRATION_DISPLAY_MS = 1000; - -export async function runStorageMigrations({ - migrations, - onProgress, -}: { - migrations: StorageMigration[]; - onProgress?: (progress: MigrationProgress) => void; -}): Promise { - // One-time cleanup: delete the old global version database - if (!hasCleanedUpMetaDb) { - try { - await deleteDatabase({ dbName: "video-editor-meta" }); - } catch { - // Ignore errors - DB might not exist - } - hasCleanedUpMetaDb = true; - } - - const projectsAdapter = new IndexedDBAdapter( - "video-editor-projects", - "projects", - 1, - ); - const projects = await projectsAdapter.getAll(); - - const orderedMigrations = [...migrations].sort((a, b) => a.from - b.from); - let migratedCount = 0; - let migrationStartTime: number | null = null; - - for (const project of projects) { - if (typeof project !== "object" || project === null) { - continue; - } - - let projectRecord = project as ProjectRecord; - let currentVersion = getProjectVersion({ project: projectRecord }); - const targetVersion = orderedMigrations.at(-1)?.to ?? currentVersion; - - if (currentVersion >= targetVersion) { - continue; - } - - // Track when we first showed the migration dialog - if (migrationStartTime === null) { - migrationStartTime = Date.now(); - } - - const projectName = getProjectName({ project: projectRecord }); - onProgress?.({ - isMigrating: true, - fromVersion: currentVersion, - toVersion: targetVersion, - projectName, - }); - - for (const migration of orderedMigrations) { - if (migration.from !== currentVersion) { - continue; - } - - const result = await migration.transform(projectRecord); - - if (result.skipped) { - break; - } - - const projectId = getProjectId({ project: result.project }); - if (!projectId) { - break; - } - - await projectsAdapter.set(projectId, result.project); - migratedCount++; - currentVersion = migration.to; - projectRecord = result.project; - } - } - - // Ensure dialog is visible for minimum time so users can see it - if (migrationStartTime !== null) { - const elapsed = Date.now() - migrationStartTime; - if (elapsed < MIN_MIGRATION_DISPLAY_MS) { - await new Promise((resolve) => - setTimeout(resolve, MIN_MIGRATION_DISPLAY_MS - elapsed), - ); - } - } - - onProgress?.({ - isMigrating: false, - fromVersion: null, - toVersion: null, - projectName: null, - }); - - return { migratedCount }; -} - -function getProjectVersion({ project }: { project: ProjectRecord }): number { - const versionValue = project.version; - - // v2 and up - has explicit version field - if (typeof versionValue === "number") { - return versionValue; - } - - // v1 - has scenes array - const scenesValue = project.scenes; - if (Array.isArray(scenesValue) && scenesValue.length > 0) { - return 1; - } - - // v0 - no scenes - return 0; -} - -function getProjectName({ - project, -}: { - project: ProjectRecord; -}): string | null { - const metadata = project.metadata; - if (isRecord(metadata) && typeof metadata.name === "string") { - return metadata.name; - } - - // v0 had name directly on project - if (typeof project.name === "string") { - return project.name; - } - - return null; -} +import { + IndexedDBAdapter, + deleteDatabase, +} from "@/services/storage/indexeddb-adapter"; +import type { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { getProjectId, isRecord } from "./transformers/utils"; + +export interface StorageMigrationResult { + migratedCount: number; +} + +export interface MigrationProgress { + isMigrating: boolean; + fromVersion: number | null; + toVersion: number | null; + projectName: string | null; +} + +let hasCleanedUpMetaDb = false; + +const MIN_MIGRATION_DISPLAY_MS = 1000; + +export async function runStorageMigrations({ + migrations, + onProgress, +}: { + migrations: StorageMigration[]; + onProgress?: (progress: MigrationProgress) => void; +}): Promise { + // One-time cleanup: delete the old global version database + if (!hasCleanedUpMetaDb) { + try { + await deleteDatabase({ dbName: "video-editor-meta" }); + } catch { + // Ignore errors - DB might not exist + } + hasCleanedUpMetaDb = true; + } + + const projectsAdapter = new IndexedDBAdapter( + "video-editor-projects", + "projects", + 1, + ); + const projects = await projectsAdapter.getAll(); + + const orderedMigrations = [...migrations].sort((a, b) => a.from - b.from); + let migratedCount = 0; + let migrationStartTime: number | null = null; + + for (const project of projects) { + if (typeof project !== "object" || project === null) { + continue; + } + + let projectRecord = project as ProjectRecord; + let currentVersion = getProjectVersion({ project: projectRecord }); + const targetVersion = orderedMigrations.at(-1)?.to ?? currentVersion; + + if (currentVersion >= targetVersion) { + continue; + } + + // Track when we first showed the migration dialog + if (migrationStartTime === null) { + migrationStartTime = Date.now(); + } + + const projectName = getProjectName({ project: projectRecord }); + onProgress?.({ + isMigrating: true, + fromVersion: currentVersion, + toVersion: targetVersion, + projectName, + }); + + for (const migration of orderedMigrations) { + if (migration.from !== currentVersion) { + continue; + } + + const result = await migration.transform(projectRecord); + + if (result.skipped) { + break; + } + + const projectId = getProjectId({ project: result.project }); + if (!projectId) { + break; + } + + await projectsAdapter.set(projectId, result.project); + migratedCount++; + currentVersion = migration.to; + projectRecord = result.project; + } + } + + // Ensure dialog is visible for minimum time so users can see it + if (migrationStartTime !== null) { + const elapsed = Date.now() - migrationStartTime; + if (elapsed < MIN_MIGRATION_DISPLAY_MS) { + await new Promise((resolve) => + setTimeout(resolve, MIN_MIGRATION_DISPLAY_MS - elapsed), + ); + } + } + + onProgress?.({ + isMigrating: false, + fromVersion: null, + toVersion: null, + projectName: null, + }); + + return { migratedCount }; +} + +function getProjectVersion({ project }: { project: ProjectRecord }): number { + const versionValue = project.version; + + // v2 and up - has explicit version field + if (typeof versionValue === "number") { + return versionValue; + } + + // v1 - has scenes array + const scenesValue = project.scenes; + if (Array.isArray(scenesValue) && scenesValue.length > 0) { + return 1; + } + + // v0 - no scenes + return 0; +} + +function getProjectName({ + project, +}: { + project: ProjectRecord; +}): string | null { + const metadata = project.metadata; + if (isRecord(metadata) && typeof metadata.name === "string") { + return metadata.name; + } + + // v0 had name directly on project + if (typeof project.name === "string") { + return project.name; + } + + return null; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts b/apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts index b48f5e88..2f803d67 100644 --- a/apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts +++ b/apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts @@ -1,113 +1,113 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV10ToV11({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - if (!getProjectId({ project })) { - return { project, skipped: true, reason: "no project id" }; - } - - if (typeof project.version === "number" && project.version >= 11) { - return { project, skipped: true, reason: "already v11" }; - } - - const migratedProject = migrateProjectScale({ project }); - - return { - project: { ...migratedProject, version: 11 }, - skipped: false, - }; -} - -function migrateProjectScale({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const scenesValue = project.scenes; - if (!Array.isArray(scenesValue)) return project; - - const migratedScenes = scenesValue.map((scene) => - migrateSceneScale({ scene }), - ); - - return { ...project, scenes: migratedScenes }; -} - -function migrateSceneScale({ scene }: { scene: unknown }): unknown { - if (!isRecord(scene)) return scene; - - const tracksValue = scene.tracks; - if (!Array.isArray(tracksValue)) return scene; - - const migratedTracks = tracksValue.map((track) => - migrateTrackScale({ track }), - ); - - return { ...scene, tracks: migratedTracks }; -} - -function migrateTrackScale({ track }: { track: unknown }): unknown { - if (!isRecord(track)) return track; - - const elementsValue = track.elements; - if (!Array.isArray(elementsValue)) return track; - - const migratedElements = elementsValue.map((element) => - migrateElementScale({ element }), - ); - - return { ...track, elements: migratedElements }; -} - -function migrateElementScale({ element }: { element: unknown }): unknown { - if (!isRecord(element)) return element; - - const transform = element.transform; - if (!isRecord(transform)) return element; - - const scale = transform.scale; - if (typeof scale !== "number") return element; - - const migratedTransform = { - ...transform, - scaleX: scale, - scaleY: scale, - }; - delete (migratedTransform as Record).scale; - - let migratedElement: ProjectRecord = { - ...element, - transform: migratedTransform, - }; - - const animations = element.animations; - if (isRecord(animations) && isRecord(animations.channels)) { - const channels = animations.channels as Record; - const scaleChannel = channels["transform.scale"]; - if (scaleChannel && isRecord(scaleChannel)) { - const keyframes = (scaleChannel as { keyframes?: unknown[] }).keyframes; - if (Array.isArray(keyframes)) { - const newChannels = { ...channels }; - delete newChannels["transform.scale"]; - newChannels["transform.scaleX"] = { - ...scaleChannel, - keyframes: [...keyframes], - }; - newChannels["transform.scaleY"] = { - ...scaleChannel, - keyframes: keyframes.map((kf) => (isRecord(kf) ? { ...kf } : kf)), - }; - migratedElement = { - ...migratedElement, - animations: { ...animations, channels: newChannels }, - }; - } - } - } - - return migratedElement; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV10ToV11({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + if (typeof project.version === "number" && project.version >= 11) { + return { project, skipped: true, reason: "already v11" }; + } + + const migratedProject = migrateProjectScale({ project }); + + return { + project: { ...migratedProject, version: 11 }, + skipped: false, + }; +} + +function migrateProjectScale({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + const migratedScenes = scenesValue.map((scene) => + migrateSceneScale({ scene }), + ); + + return { ...project, scenes: migratedScenes }; +} + +function migrateSceneScale({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + const migratedTracks = tracksValue.map((track) => + migrateTrackScale({ track }), + ); + + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackScale({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + const migratedElements = elementsValue.map((element) => + migrateElementScale({ element }), + ); + + return { ...track, elements: migratedElements }; +} + +function migrateElementScale({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + + const transform = element.transform; + if (!isRecord(transform)) return element; + + const scale = transform.scale; + if (typeof scale !== "number") return element; + + const migratedTransform = { + ...transform, + scaleX: scale, + scaleY: scale, + }; + delete (migratedTransform as Record).scale; + + let migratedElement: ProjectRecord = { + ...element, + transform: migratedTransform, + }; + + const animations = element.animations; + if (isRecord(animations) && isRecord(animations.channels)) { + const channels = animations.channels as Record; + const scaleChannel = channels["transform.scale"]; + if (scaleChannel && isRecord(scaleChannel)) { + const keyframes = (scaleChannel as { keyframes?: unknown[] }).keyframes; + if (Array.isArray(keyframes)) { + const newChannels = { ...channels }; + delete newChannels["transform.scale"]; + newChannels["transform.scaleX"] = { + ...scaleChannel, + keyframes: [...keyframes], + }; + newChannels["transform.scaleY"] = { + ...scaleChannel, + keyframes: keyframes.map((kf) => (isRecord(kf) ? { ...kf } : kf)), + }; + migratedElement = { + ...migratedElement, + animations: { ...animations, channels: newChannels }, + }; + } + } + } + + return migratedElement; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts b/apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts index fa1bd371..4efc5b25 100644 --- a/apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts +++ b/apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts @@ -1,196 +1,196 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV11ToV12({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - if (!getProjectId({ project })) { - return { project, skipped: true, reason: "no project id" }; - } - - if (typeof project.version === "number" && project.version >= 12) { - return { project, skipped: true, reason: "already v12" }; - } - - const migratedProject = migrateProjectPosition({ project }); - - return { - project: { ...migratedProject, version: 12 }, - skipped: false, - }; -} - -function migrateProjectPosition({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const scenesValue = project.scenes; - if (!Array.isArray(scenesValue)) return project; - - const migratedScenes = scenesValue.map((scene) => - migrateScenePosition({ scene }), - ); - - return { ...project, scenes: migratedScenes }; -} - -function migrateScenePosition({ scene }: { scene: unknown }): unknown { - if (!isRecord(scene)) return scene; - - const tracksValue = scene.tracks; - if (!Array.isArray(tracksValue)) return scene; - - const migratedTracks = tracksValue.map((track) => - migrateTrackPosition({ track }), - ); - - return { ...scene, tracks: migratedTracks }; -} - -function migrateTrackPosition({ track }: { track: unknown }): unknown { - if (!isRecord(track)) return track; - - const elementsValue = track.elements; - if (!Array.isArray(elementsValue)) return track; - - const migratedElements = elementsValue.map((element) => - migrateElementPosition({ element }), - ); - - return { ...track, elements: migratedElements }; -} - -function migrateElementPosition({ element }: { element: unknown }): unknown { - if (!isRecord(element)) return element; - - const animations = element.animations; - if (!isRecord(animations) || !isRecord(animations.channels)) return element; - - const channels = animations.channels as Record; - const xChannel = channels["transform.position.x"]; - const yChannel = channels["transform.position.y"]; - - if (!xChannel && !yChannel) return element; - - const baseTransform = isRecord(element.transform) ? element.transform : {}; - const basePosition = isRecord(baseTransform.position) - ? baseTransform.position - : {}; - const baseX = typeof basePosition.x === "number" ? basePosition.x : 0; - const baseY = typeof basePosition.y === "number" ? basePosition.y : 0; - - const xKeyframes = getKeyframes({ channel: xChannel }); - const yKeyframes = getKeyframes({ channel: yChannel }); - - const allTimes = Array.from( - new Set([ - ...xKeyframes.map((kf) => kf.time), - ...yKeyframes.map((kf) => kf.time), - ]), - ).sort((a, b) => a - b); - - const vectorKeyframes = allTimes.map((time) => { - const xKf = xKeyframes.find((kf) => Math.abs(kf.time - time) < 0.001); - const yKf = yKeyframes.find((kf) => Math.abs(kf.time - time) < 0.001); - const x = - typeof xKf?.value === "number" - ? xKf.value - : interpolateScalarAtTime({ - keyframes: xKeyframes, - time, - fallback: baseX, - }); - const y = - typeof yKf?.value === "number" - ? yKf.value - : interpolateScalarAtTime({ - keyframes: yKeyframes, - time, - fallback: baseY, - }); - const interpolation = xKf?.interpolation ?? yKf?.interpolation ?? "linear"; - return { - id: xKf?.id ?? yKf?.id ?? crypto.randomUUID(), - time, - value: { x, y }, - interpolation, - }; - }); - - const newChannels = { ...channels }; - delete newChannels["transform.position.x"]; - delete newChannels["transform.position.y"]; - newChannels["transform.position"] = { - valueKind: "vector", - keyframes: vectorKeyframes, - }; - - return { - ...element, - animations: { ...animations, channels: newChannels }, - }; -} - -interface ScalarKeyframe { - id: string; - time: number; - value: number; - interpolation: string; -} - -function getKeyframes({ channel }: { channel: unknown }): ScalarKeyframe[] { - if (!isRecord(channel)) return []; - if (!Array.isArray(channel.keyframes)) return []; - return channel.keyframes.flatMap((kf) => { - if (!isRecord(kf)) return []; - if ( - typeof kf.id !== "string" || - typeof kf.time !== "number" || - typeof kf.value !== "number" - ) - return []; - return [ - { - id: kf.id, - time: kf.time, - value: kf.value, - interpolation: - typeof kf.interpolation === "string" ? kf.interpolation : "linear", - }, - ]; - }); -} - -function interpolateScalarAtTime({ - keyframes, - time, - fallback, -}: { - keyframes: ScalarKeyframe[]; - time: number; - fallback: number; -}): number { - if (keyframes.length === 0) return fallback; - - const sorted = [...keyframes].sort((a, b) => a.time - b.time); - const first = sorted[0]; - const last = sorted[sorted.length - 1]; - - if (!first || !last) return fallback; - if (time <= first.time) return first.value; - if (time >= last.time) return last.value; - - for (let i = 0; i < sorted.length - 1; i++) { - const left = sorted[i]; - const right = sorted[i + 1]; - if (time < left.time || time > right.time) continue; - if (left.interpolation === "hold") return left.value; - const t = (time - left.time) / (right.time - left.time); - return left.value + (right.value - left.value) * t; - } - - return last.value; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV11ToV12({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + if (typeof project.version === "number" && project.version >= 12) { + return { project, skipped: true, reason: "already v12" }; + } + + const migratedProject = migrateProjectPosition({ project }); + + return { + project: { ...migratedProject, version: 12 }, + skipped: false, + }; +} + +function migrateProjectPosition({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + const migratedScenes = scenesValue.map((scene) => + migrateScenePosition({ scene }), + ); + + return { ...project, scenes: migratedScenes }; +} + +function migrateScenePosition({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + const migratedTracks = tracksValue.map((track) => + migrateTrackPosition({ track }), + ); + + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackPosition({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + const migratedElements = elementsValue.map((element) => + migrateElementPosition({ element }), + ); + + return { ...track, elements: migratedElements }; +} + +function migrateElementPosition({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + + const animations = element.animations; + if (!isRecord(animations) || !isRecord(animations.channels)) return element; + + const channels = animations.channels as Record; + const xChannel = channels["transform.position.x"]; + const yChannel = channels["transform.position.y"]; + + if (!xChannel && !yChannel) return element; + + const baseTransform = isRecord(element.transform) ? element.transform : {}; + const basePosition = isRecord(baseTransform.position) + ? baseTransform.position + : {}; + const baseX = typeof basePosition.x === "number" ? basePosition.x : 0; + const baseY = typeof basePosition.y === "number" ? basePosition.y : 0; + + const xKeyframes = getKeyframes({ channel: xChannel }); + const yKeyframes = getKeyframes({ channel: yChannel }); + + const allTimes = Array.from( + new Set([ + ...xKeyframes.map((kf) => kf.time), + ...yKeyframes.map((kf) => kf.time), + ]), + ).sort((a, b) => a - b); + + const vectorKeyframes = allTimes.map((time) => { + const xKf = xKeyframes.find((kf) => Math.abs(kf.time - time) < 0.001); + const yKf = yKeyframes.find((kf) => Math.abs(kf.time - time) < 0.001); + const x = + typeof xKf?.value === "number" + ? xKf.value + : interpolateScalarAtTime({ + keyframes: xKeyframes, + time, + fallback: baseX, + }); + const y = + typeof yKf?.value === "number" + ? yKf.value + : interpolateScalarAtTime({ + keyframes: yKeyframes, + time, + fallback: baseY, + }); + const interpolation = xKf?.interpolation ?? yKf?.interpolation ?? "linear"; + return { + id: xKf?.id ?? yKf?.id ?? crypto.randomUUID(), + time, + value: { x, y }, + interpolation, + }; + }); + + const newChannels = { ...channels }; + delete newChannels["transform.position.x"]; + delete newChannels["transform.position.y"]; + newChannels["transform.position"] = { + valueKind: "vector", + keyframes: vectorKeyframes, + }; + + return { + ...element, + animations: { ...animations, channels: newChannels }, + }; +} + +interface ScalarKeyframe { + id: string; + time: number; + value: number; + interpolation: string; +} + +function getKeyframes({ channel }: { channel: unknown }): ScalarKeyframe[] { + if (!isRecord(channel)) return []; + if (!Array.isArray(channel.keyframes)) return []; + return channel.keyframes.flatMap((kf) => { + if (!isRecord(kf)) return []; + if ( + typeof kf.id !== "string" || + typeof kf.time !== "number" || + typeof kf.value !== "number" + ) + return []; + return [ + { + id: kf.id, + time: kf.time, + value: kf.value, + interpolation: + typeof kf.interpolation === "string" ? kf.interpolation : "linear", + }, + ]; + }); +} + +function interpolateScalarAtTime({ + keyframes, + time, + fallback, +}: { + keyframes: ScalarKeyframe[]; + time: number; + fallback: number; +}): number { + if (keyframes.length === 0) return fallback; + + const sorted = [...keyframes].sort((a, b) => a.time - b.time); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + + if (!first || !last) return fallback; + if (time <= first.time) return first.value; + if (time >= last.time) return last.value; + + for (let i = 0; i < sorted.length - 1; i++) { + const left = sorted[i]; + const right = sorted[i + 1]; + if (time < left.time || time > right.time) continue; + if (left.interpolation === "hold") return left.value; + const t = (time - left.time) / (right.time - left.time); + return left.value + (right.value - left.value) * t; + } + + return last.value; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts b/apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts index 59b29732..518de59a 100644 --- a/apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts +++ b/apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts @@ -1,102 +1,102 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV12ToV13({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - if (!getProjectId({ project })) { - return { project, skipped: true, reason: "no project id" }; - } - - if (typeof project.version === "number" && project.version >= 13) { - return { project, skipped: true, reason: "already v13" }; - } - - const migratedProject = migrateProjectMasks({ project }); - - return { - project: { ...migratedProject, version: 13 }, - skipped: false, - }; -} - -function migrateProjectMasks({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const scenesValue = project.scenes; - if (!Array.isArray(scenesValue)) return project; - - const migratedScenes = scenesValue.map((scene) => - migrateSceneMasks({ scene }), - ); - - return { ...project, scenes: migratedScenes }; -} - -function migrateSceneMasks({ scene }: { scene: unknown }): unknown { - if (!isRecord(scene)) return scene; - - const tracksValue = scene.tracks; - if (!Array.isArray(tracksValue)) return scene; - - const migratedTracks = tracksValue.map((track) => - migrateTrackMasks({ track }), - ); - - return { ...scene, tracks: migratedTracks }; -} - -function migrateTrackMasks({ track }: { track: unknown }): unknown { - if (!isRecord(track)) return track; - - const elementsValue = track.elements; - if (!Array.isArray(elementsValue)) return track; - - const migratedElements = elementsValue.map((element) => - migrateElementMasks({ element }), - ); - - return { ...track, elements: migratedElements }; -} - -function migrateElementMasks({ element }: { element: unknown }): unknown { - if (!isRecord(element)) return element; - - const masksValue = element.masks; - if (!Array.isArray(masksValue)) return element; - - const migratedMasks = masksValue.map((mask) => migrateMask({ mask })); - - return { ...element, masks: migratedMasks }; -} - -function migrateMask({ mask }: { mask: unknown }): unknown { - if (!isRecord(mask)) return mask; - - const params = isRecord(mask.params) ? { ...mask.params } : {}; - const result = { ...mask }; - - result.params = { - ...params, - feather: typeof mask.feather === "number" ? mask.feather : 0, - inverted: typeof mask.inverted === "boolean" ? mask.inverted : false, - strokeColor: - isRecord(mask.stroke) && typeof mask.stroke.color === "string" - ? mask.stroke.color - : "#ffffff", - strokeWidth: - isRecord(mask.stroke) && typeof mask.stroke.width === "number" - ? mask.stroke.width - : 0, - }; - - delete (result as Record).feather; - delete (result as Record).inverted; - delete (result as Record).stroke; - - return result; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV12ToV13({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + if (typeof project.version === "number" && project.version >= 13) { + return { project, skipped: true, reason: "already v13" }; + } + + const migratedProject = migrateProjectMasks({ project }); + + return { + project: { ...migratedProject, version: 13 }, + skipped: false, + }; +} + +function migrateProjectMasks({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + const migratedScenes = scenesValue.map((scene) => + migrateSceneMasks({ scene }), + ); + + return { ...project, scenes: migratedScenes }; +} + +function migrateSceneMasks({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + const migratedTracks = tracksValue.map((track) => + migrateTrackMasks({ track }), + ); + + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackMasks({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + const migratedElements = elementsValue.map((element) => + migrateElementMasks({ element }), + ); + + return { ...track, elements: migratedElements }; +} + +function migrateElementMasks({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + + const masksValue = element.masks; + if (!Array.isArray(masksValue)) return element; + + const migratedMasks = masksValue.map((mask) => migrateMask({ mask })); + + return { ...element, masks: migratedMasks }; +} + +function migrateMask({ mask }: { mask: unknown }): unknown { + if (!isRecord(mask)) return mask; + + const params = isRecord(mask.params) ? { ...mask.params } : {}; + const result = { ...mask }; + + result.params = { + ...params, + feather: typeof mask.feather === "number" ? mask.feather : 0, + inverted: typeof mask.inverted === "boolean" ? mask.inverted : false, + strokeColor: + isRecord(mask.stroke) && typeof mask.stroke.color === "string" + ? mask.stroke.color + : "#ffffff", + strokeWidth: + isRecord(mask.stroke) && typeof mask.stroke.width === "number" + ? mask.stroke.width + : 0, + }; + + delete (result as Record).feather; + delete (result as Record).inverted; + delete (result as Record).stroke; + + return result; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts index 01a3cc3e..89dde38e 100644 --- a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts +++ b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts @@ -1,106 +1,106 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV13ToV14({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - if (!getProjectId({ project })) { - return { project, skipped: true, reason: "no project id" }; - } - - if (typeof project.version === "number" && project.version >= 14) { - return { project, skipped: true, reason: "already v14" }; - } - - const migratedProject = migrateProjectSplitMasks({ project }); - - return { - project: { ...migratedProject, version: 14 }, - skipped: false, - }; -} - -function migrateProjectSplitMasks({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const scenesValue = project.scenes; - if (!Array.isArray(scenesValue)) return project; - - const migratedScenes = scenesValue.map((scene) => - migrateSceneSplitMasks({ scene }), - ); - - return { ...project, scenes: migratedScenes }; -} - -function migrateSceneSplitMasks({ scene }: { scene: unknown }): unknown { - if (!isRecord(scene)) return scene; - - const tracksValue = scene.tracks; - if (!Array.isArray(tracksValue)) return scene; - - const migratedTracks = tracksValue.map((track) => - migrateTrackSplitMasks({ track }), - ); - - return { ...scene, tracks: migratedTracks }; -} - -function migrateTrackSplitMasks({ track }: { track: unknown }): unknown { - if (!isRecord(track)) return track; - - const elementsValue = track.elements; - if (!Array.isArray(elementsValue)) return track; - - const migratedElements = elementsValue.map((element) => - migrateElementSplitMasks({ element }), - ); - - return { ...track, elements: migratedElements }; -} - -function migrateElementSplitMasks({ element }: { element: unknown }): unknown { - if (!isRecord(element)) return element; - - const masksValue = element.masks; - if (!Array.isArray(masksValue)) return element; - - const migratedMasks = masksValue.map((mask) => migrateSplitMask({ mask })); - - return { ...element, masks: migratedMasks }; -} - -/** - * Converts split mask params from position-along-normal to explicit (x, y) anchor. - * - * Old: center = (cx + (position-0.5)*W*cos(rot), cy + (position-0.5)*W*sin(rot)) - * New: center = (cx + x*W, cy + y*W) - * - * Conversion: x = (position-0.5)*cos(rot), y = (position-0.5)*sin(rot) - */ -function migrateSplitMask({ mask }: { mask: unknown }): unknown { - if (!isRecord(mask)) return mask; - if (mask.type !== "split") return mask; - - const params = isRecord(mask.params) ? mask.params : {}; - const position = typeof params.position === "number" ? params.position : 0.5; - const rotation = typeof params.rotation === "number" ? params.rotation : 0; - - const angleRad = (rotation * Math.PI) / 180; - const x = (position - 0.5) * Math.cos(angleRad); - const y = (position - 0.5) * Math.sin(angleRad); - - const { position: _removed, ...restParams } = params as Record< - string, - unknown - > & { position: unknown }; - - return { - ...mask, - params: { ...restParams, x, y }, - }; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV13ToV14({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + if (typeof project.version === "number" && project.version >= 14) { + return { project, skipped: true, reason: "already v14" }; + } + + const migratedProject = migrateProjectSplitMasks({ project }); + + return { + project: { ...migratedProject, version: 14 }, + skipped: false, + }; +} + +function migrateProjectSplitMasks({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + const migratedScenes = scenesValue.map((scene) => + migrateSceneSplitMasks({ scene }), + ); + + return { ...project, scenes: migratedScenes }; +} + +function migrateSceneSplitMasks({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + const migratedTracks = tracksValue.map((track) => + migrateTrackSplitMasks({ track }), + ); + + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackSplitMasks({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + const migratedElements = elementsValue.map((element) => + migrateElementSplitMasks({ element }), + ); + + return { ...track, elements: migratedElements }; +} + +function migrateElementSplitMasks({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + + const masksValue = element.masks; + if (!Array.isArray(masksValue)) return element; + + const migratedMasks = masksValue.map((mask) => migrateSplitMask({ mask })); + + return { ...element, masks: migratedMasks }; +} + +/** + * Converts split mask params from position-along-normal to explicit (x, y) anchor. + * + * Old: center = (cx + (position-0.5)*W*cos(rot), cy + (position-0.5)*W*sin(rot)) + * New: center = (cx + x*W, cy + y*W) + * + * Conversion: x = (position-0.5)*cos(rot), y = (position-0.5)*sin(rot) + */ +function migrateSplitMask({ mask }: { mask: unknown }): unknown { + if (!isRecord(mask)) return mask; + if (mask.type !== "split") return mask; + + const params = isRecord(mask.params) ? mask.params : {}; + const position = typeof params.position === "number" ? params.position : 0.5; + const rotation = typeof params.rotation === "number" ? params.rotation : 0; + + const angleRad = (rotation * Math.PI) / 180; + const x = (position - 0.5) * Math.cos(angleRad); + const y = (position - 0.5) * Math.sin(angleRad); + + const { position: _removed, ...restParams } = params as Record< + string, + unknown + > & { position: unknown }; + + return { + ...mask, + params: { ...restParams, x, y }, + }; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts b/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts index beef05d4..e75c541b 100644 --- a/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts +++ b/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts @@ -1,67 +1,67 @@ -import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants"; -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV14ToV15({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - if (!getProjectId({ project })) { - return { project, skipped: true, reason: "no project id" }; - } - - if (typeof project.version === "number" && project.version >= 15) { - return { project, skipped: true, reason: "already v15" }; - } - - const migratedProject = backfillStickerIntrinsicDimensions({ project }); - - return { - project: { ...migratedProject, version: 15 }, - skipped: false, - }; -} - -function backfillStickerIntrinsicDimensions({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const scenesValue = project.scenes; - if (!Array.isArray(scenesValue)) return project; - - const migratedScenes = scenesValue.map((scene) => { - if (!isRecord(scene)) return scene; - const tracksValue = scene.tracks; - if (!Array.isArray(tracksValue)) return scene; - - const migratedTracks = tracksValue.map((track) => { - if (!isRecord(track)) return track; - const elementsValue = track.elements; - if (!Array.isArray(elementsValue)) return track; - - const migratedElements = elementsValue.map((element) => { - if (!isRecord(element)) return element; - if (element.type !== "sticker") return element; - if ( - typeof element.intrinsicWidth === "number" && - typeof element.intrinsicHeight === "number" - ) { - return element; - } - return { - ...element, - intrinsicWidth: STICKER_INTRINSIC_SIZE_FALLBACK, - intrinsicHeight: STICKER_INTRINSIC_SIZE_FALLBACK, - }; - }); - - return { ...track, elements: migratedElements }; - }); - - return { ...scene, tracks: migratedTracks }; - }); - - return { ...project, scenes: migratedScenes }; -} +import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants"; +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV14ToV15({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + if (typeof project.version === "number" && project.version >= 15) { + return { project, skipped: true, reason: "already v15" }; + } + + const migratedProject = backfillStickerIntrinsicDimensions({ project }); + + return { + project: { ...migratedProject, version: 15 }, + skipped: false, + }; +} + +function backfillStickerIntrinsicDimensions({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + const migratedScenes = scenesValue.map((scene) => { + if (!isRecord(scene)) return scene; + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + const migratedTracks = tracksValue.map((track) => { + if (!isRecord(track)) return track; + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + const migratedElements = elementsValue.map((element) => { + if (!isRecord(element)) return element; + if (element.type !== "sticker") return element; + if ( + typeof element.intrinsicWidth === "number" && + typeof element.intrinsicHeight === "number" + ) { + return element; + } + return { + ...element, + intrinsicWidth: STICKER_INTRINSIC_SIZE_FALLBACK, + intrinsicHeight: STICKER_INTRINSIC_SIZE_FALLBACK, + }; + }); + + return { ...track, elements: migratedElements }; + }); + + return { ...scene, tracks: migratedTracks }; + }); + + return { ...project, scenes: migratedScenes }; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts b/apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts index 15e2f3bb..746938d0 100644 --- a/apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts +++ b/apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts @@ -1,60 +1,60 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV15ToV16({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - if (!getProjectId({ project })) { - return { project, skipped: true, reason: "no project id" }; - } - - if (typeof project.version === "number" && project.version >= 16) { - return { project, skipped: true, reason: "already v16" }; - } - - return { - project: { - ...renameStickerTracksToGraphic({ project }), - version: 16, - }, - skipped: false, - }; -} - -function renameStickerTracksToGraphic({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const scenesValue = project.scenes; - if (!Array.isArray(scenesValue)) { - return project; - } - - const migratedScenes = scenesValue.map((scene) => { - if (!isRecord(scene) || !Array.isArray(scene.tracks)) { - return scene; - } - - return { - ...scene, - tracks: scene.tracks.map((track) => { - if (!isRecord(track) || track.type !== "sticker") { - return track; - } - - return { - ...track, - type: "graphic", - }; - }), - }; - }); - - return { - ...project, - scenes: migratedScenes, - }; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV15ToV16({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + if (typeof project.version === "number" && project.version >= 16) { + return { project, skipped: true, reason: "already v16" }; + } + + return { + project: { + ...renameStickerTracksToGraphic({ project }), + version: 16, + }, + skipped: false, + }; +} + +function renameStickerTracksToGraphic({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) { + return project; + } + + const migratedScenes = scenesValue.map((scene) => { + if (!isRecord(scene) || !Array.isArray(scene.tracks)) { + return scene; + } + + return { + ...scene, + tracks: scene.tracks.map((track) => { + if (!isRecord(track) || track.type !== "sticker") { + return track; + } + + return { + ...track, + type: "graphic", + }; + }), + }; + }); + + return { + ...project, + scenes: migratedScenes, + }; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts b/apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts index 7fec70e9..00474188 100644 --- a/apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts +++ b/apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts @@ -1,107 +1,107 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV16ToV17({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - if (!getProjectId({ project })) { - return { project, skipped: true, reason: "no project id" }; - } - - if (typeof project.version === "number" && project.version >= 17) { - return { project, skipped: true, reason: "already v17" }; - } - - return { - project: { - ...backfillMaskStrokeAlign({ project }), - version: 17, - }, - skipped: false, - }; -} - -function backfillMaskStrokeAlign({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const scenesValue = project.scenes; - if (!Array.isArray(scenesValue)) { - return project; - } - - const migratedScenes = scenesValue.map((scene) => { - if (!isRecord(scene)) { - return scene; - } - - const tracksValue = scene.tracks; - if (!Array.isArray(tracksValue)) { - return scene; - } - - const migratedTracks = tracksValue.map((track) => { - if (!isRecord(track)) { - return track; - } - - const elementsValue = track.elements; - if (!Array.isArray(elementsValue)) { - return track; - } - - const migratedElements = elementsValue.map((element) => { - if (!isRecord(element)) { - return element; - } - - const masksValue = element.masks; - if (!Array.isArray(masksValue)) { - return element; - } - - const migratedMasks = masksValue.map((mask) => { - if (!isRecord(mask)) { - return mask; - } - - const paramsValue = mask.params; - if (!isRecord(paramsValue) || typeof paramsValue.strokeAlign === "string") { - return mask; - } - - return { - ...mask, - params: { - ...paramsValue, - strokeAlign: "center", - }, - }; - }); - - return { - ...element, - masks: migratedMasks, - }; - }); - - return { - ...track, - elements: migratedElements, - }; - }); - - return { - ...scene, - tracks: migratedTracks, - }; - }); - - return { - ...project, - scenes: migratedScenes, - }; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV16ToV17({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + if (typeof project.version === "number" && project.version >= 17) { + return { project, skipped: true, reason: "already v17" }; + } + + return { + project: { + ...backfillMaskStrokeAlign({ project }), + version: 17, + }, + skipped: false, + }; +} + +function backfillMaskStrokeAlign({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) { + return project; + } + + const migratedScenes = scenesValue.map((scene) => { + if (!isRecord(scene)) { + return scene; + } + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) { + return scene; + } + + const migratedTracks = tracksValue.map((track) => { + if (!isRecord(track)) { + return track; + } + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) { + return track; + } + + const migratedElements = elementsValue.map((element) => { + if (!isRecord(element)) { + return element; + } + + const masksValue = element.masks; + if (!Array.isArray(masksValue)) { + return element; + } + + const migratedMasks = masksValue.map((mask) => { + if (!isRecord(mask)) { + return mask; + } + + const paramsValue = mask.params; + if (!isRecord(paramsValue) || typeof paramsValue.strokeAlign === "string") { + return mask; + } + + return { + ...mask, + params: { + ...paramsValue, + strokeAlign: "center", + }, + }; + }); + + return { + ...element, + masks: migratedMasks, + }; + }); + + return { + ...track, + elements: migratedElements, + }; + }); + + return { + ...scene, + tracks: migratedTracks, + }; + }); + + return { + ...project, + scenes: migratedScenes, + }; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts b/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts index 31a60121..5dcf6feb 100644 --- a/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts +++ b/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts @@ -1,105 +1,105 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; -import { clampDb } from "@/lib/timeline/audio-state"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV17ToV18({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - if (!getProjectId({ project })) { - return { project, skipped: true, reason: "no project id" }; - } - - if (typeof project.version === "number" && project.version >= 18) { - return { project, skipped: true, reason: "already v18" }; - } - - return { - project: { - ...migrateElementVolumes({ project }), - version: 18, - }, - skipped: false, - }; -} - -function migrateElementVolumes({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const scenesValue = project.scenes; - if (!Array.isArray(scenesValue)) { - return project; - } - - const migratedScenes = scenesValue.map((scene) => { - if (!isRecord(scene)) { - return scene; - } - - const tracksValue = scene.tracks; - if (!Array.isArray(tracksValue)) { - return scene; - } - - const migratedTracks = tracksValue.map((track) => { - if (!isRecord(track)) { - return track; - } - - const elementsValue = track.elements; - if (!Array.isArray(elementsValue)) { - return track; - } - - const migratedElements = elementsValue.map((element) => { - if (!isRecord(element)) { - return element; - } - - if (element.type !== "audio" && element.type !== "video") { - return element; - } - - const legacyVolume = element.volume; - return { - ...element, - volume: - typeof legacyVolume === "number" - ? linearGainToDb(legacyVolume) - : 0, - }; - }); - - return { - ...track, - elements: migratedElements, - }; - }); - - return { - ...scene, - tracks: migratedTracks, - }; - }); - - return { - ...project, - scenes: migratedScenes, - }; -} - -function linearGainToDb(gain: number): number { - if (!Number.isFinite(gain)) { - return 0; - } - - if (gain <= 0) { - return VOLUME_DB_MIN; - } - - return clampDb(20 * Math.log10(gain)); -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; +import { clampDb } from "@/lib/timeline/audio-state"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV17ToV18({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + if (typeof project.version === "number" && project.version >= 18) { + return { project, skipped: true, reason: "already v18" }; + } + + return { + project: { + ...migrateElementVolumes({ project }), + version: 18, + }, + skipped: false, + }; +} + +function migrateElementVolumes({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) { + return project; + } + + const migratedScenes = scenesValue.map((scene) => { + if (!isRecord(scene)) { + return scene; + } + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) { + return scene; + } + + const migratedTracks = tracksValue.map((track) => { + if (!isRecord(track)) { + return track; + } + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) { + return track; + } + + const migratedElements = elementsValue.map((element) => { + if (!isRecord(element)) { + return element; + } + + if (element.type !== "audio" && element.type !== "video") { + return element; + } + + const legacyVolume = element.volume; + return { + ...element, + volume: + typeof legacyVolume === "number" + ? linearGainToDb(legacyVolume) + : 0, + }; + }); + + return { + ...track, + elements: migratedElements, + }; + }); + + return { + ...scene, + tracks: migratedTracks, + }; + }); + + return { + ...project, + scenes: migratedScenes, + }; +} + +function linearGainToDb(gain: number): number { + if (!Number.isFinite(gain)) { + return 0; + } + + if (gain <= 0) { + return VOLUME_DB_MIN; + } + + return clampDb(20 * Math.log10(gain)); +} diff --git a/apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts b/apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts index 1946b829..3afa5203 100644 --- a/apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts +++ b/apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts @@ -1,46 +1,46 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV18ToV19({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - if (!getProjectId({ project })) { - return { project, skipped: true, reason: "no project id" }; - } - - if (typeof project.version === "number" && project.version >= 19) { - return { project, skipped: true, reason: "already v19" }; - } - - return { - project: { - ...migrateCanvasSizeSettings({ project }), - version: 19, - }, - skipped: false, - }; -} - -function migrateCanvasSizeSettings({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const settingsValue = project.settings; - if (!isRecord(settingsValue)) { - return project; - } - - const migratedSettings: Record = { - ...settingsValue, - canvasSizeMode: "preset", - lastCustomCanvasSize: null, - }; - - return { - ...project, - settings: migratedSettings, - }; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV18ToV19({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + if (typeof project.version === "number" && project.version >= 19) { + return { project, skipped: true, reason: "already v19" }; + } + + return { + project: { + ...migrateCanvasSizeSettings({ project }), + version: 19, + }, + skipped: false, + }; +} + +function migrateCanvasSizeSettings({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const settingsValue = project.settings; + if (!isRecord(settingsValue)) { + return project; + } + + const migratedSettings: Record = { + ...settingsValue, + canvasSizeMode: "preset", + lastCustomCanvasSize: null, + }; + + return { + ...project, + settings: migratedSettings, + }; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts index d6e1efee..84d97fd8 100644 --- a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts +++ b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts @@ -1,189 +1,189 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -const LEGACY_FONT_WEIGHT_MAP = { - normal: "400", - bold: "700", -} as const; - -const VALID_NUMERIC_FONT_WEIGHTS = new Set([ - "100", - "200", - "300", - "400", - "500", - "600", - "700", - "800", - "900", -]); - -export function transformProjectV3ToV4({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - const projectId = getProjectId({ project }); - if (!projectId) { - return { project, skipped: true, reason: "no project id" }; - } - - if (isV4Project({ project })) { - return { project, skipped: true, reason: "already v4" }; - } - - const migratedProject = normalizeProjectTextFontWeights({ project }); - - return { - project: { - ...migratedProject, - version: 4, - }, - skipped: false, - }; -} - -function normalizeProjectTextFontWeights({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const scenesValue = project.scenes; - if (!Array.isArray(scenesValue)) { - return project; - } - - let hasSceneChanges = false; - const normalizedScenes = scenesValue.map((scene) => { - const normalizedScene = normalizeSceneTextFontWeights({ scene }); - if (normalizedScene !== scene) { - hasSceneChanges = true; - } - return normalizedScene; - }); - - if (!hasSceneChanges) { - return project; - } - - return { - ...project, - scenes: normalizedScenes, - }; -} - -function normalizeSceneTextFontWeights({ scene }: { scene: unknown }): unknown { - if (!isRecord(scene)) { - return scene; - } - - const tracksValue = scene.tracks; - if (!Array.isArray(tracksValue)) { - return scene; - } - - let hasTrackChanges = false; - const normalizedTracks = tracksValue.map((track) => { - const normalizedTrack = normalizeTrackTextFontWeights({ track }); - if (normalizedTrack !== track) { - hasTrackChanges = true; - } - return normalizedTrack; - }); - - if (!hasTrackChanges) { - return scene; - } - - return { - ...scene, - tracks: normalizedTracks, - }; -} - -function normalizeTrackTextFontWeights({ track }: { track: unknown }): unknown { - if (!isRecord(track)) { - return track; - } - - if (track.type !== "text") { - return track; - } - - const elementsValue = track.elements; - if (!Array.isArray(elementsValue)) { - return track; - } - - let hasElementChanges = false; - const normalizedElements = elementsValue.map((element) => { - const normalizedElement = normalizeTextElementFontWeight({ element }); - if (normalizedElement !== element) { - hasElementChanges = true; - } - return normalizedElement; - }); - - if (!hasElementChanges) { - return track; - } - - return { - ...track, - elements: normalizedElements, - }; -} - -function normalizeTextElementFontWeight({ - element, -}: { - element: unknown; -}): unknown { - if (!isRecord(element) || element.type !== "text") { - return element; - } - - const normalizedWeight = normalizeFontWeight({ value: element.fontWeight }); - if (normalizedWeight === element.fontWeight) { - return element; - } - - return { - ...element, - fontWeight: normalizedWeight, - }; -} - -function normalizeFontWeight({ value }: { value: unknown }): unknown { - if (typeof value === "number") { - const numericWeight = String(value); - if (VALID_NUMERIC_FONT_WEIGHTS.has(numericWeight)) { - return numericWeight; - } - return value; - } - - if (typeof value !== "string") { - return value; - } - - const normalized = value.trim().toLowerCase(); - if (normalized in LEGACY_FONT_WEIGHT_MAP) { - return LEGACY_FONT_WEIGHT_MAP[ - normalized as keyof typeof LEGACY_FONT_WEIGHT_MAP - ]; - } - - if (VALID_NUMERIC_FONT_WEIGHTS.has(normalized)) { - return normalized; - } - - return value; -} - -export { getProjectId } from "./utils"; - -function isV4Project({ project }: { project: ProjectRecord }): boolean { - const versionValue = project.version; - return typeof versionValue === "number" && versionValue >= 4; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +const LEGACY_FONT_WEIGHT_MAP = { + normal: "400", + bold: "700", +} as const; + +const VALID_NUMERIC_FONT_WEIGHTS = new Set([ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", +]); + +export function transformProjectV3ToV4({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV4Project({ project })) { + return { project, skipped: true, reason: "already v4" }; + } + + const migratedProject = normalizeProjectTextFontWeights({ project }); + + return { + project: { + ...migratedProject, + version: 4, + }, + skipped: false, + }; +} + +function normalizeProjectTextFontWeights({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) { + return project; + } + + let hasSceneChanges = false; + const normalizedScenes = scenesValue.map((scene) => { + const normalizedScene = normalizeSceneTextFontWeights({ scene }); + if (normalizedScene !== scene) { + hasSceneChanges = true; + } + return normalizedScene; + }); + + if (!hasSceneChanges) { + return project; + } + + return { + ...project, + scenes: normalizedScenes, + }; +} + +function normalizeSceneTextFontWeights({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) { + return scene; + } + + let hasTrackChanges = false; + const normalizedTracks = tracksValue.map((track) => { + const normalizedTrack = normalizeTrackTextFontWeights({ track }); + if (normalizedTrack !== track) { + hasTrackChanges = true; + } + return normalizedTrack; + }); + + if (!hasTrackChanges) { + return scene; + } + + return { + ...scene, + tracks: normalizedTracks, + }; +} + +function normalizeTrackTextFontWeights({ track }: { track: unknown }): unknown { + if (!isRecord(track)) { + return track; + } + + if (track.type !== "text") { + return track; + } + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) { + return track; + } + + let hasElementChanges = false; + const normalizedElements = elementsValue.map((element) => { + const normalizedElement = normalizeTextElementFontWeight({ element }); + if (normalizedElement !== element) { + hasElementChanges = true; + } + return normalizedElement; + }); + + if (!hasElementChanges) { + return track; + } + + return { + ...track, + elements: normalizedElements, + }; +} + +function normalizeTextElementFontWeight({ + element, +}: { + element: unknown; +}): unknown { + if (!isRecord(element) || element.type !== "text") { + return element; + } + + const normalizedWeight = normalizeFontWeight({ value: element.fontWeight }); + if (normalizedWeight === element.fontWeight) { + return element; + } + + return { + ...element, + fontWeight: normalizedWeight, + }; +} + +function normalizeFontWeight({ value }: { value: unknown }): unknown { + if (typeof value === "number") { + const numericWeight = String(value); + if (VALID_NUMERIC_FONT_WEIGHTS.has(numericWeight)) { + return numericWeight; + } + return value; + } + + if (typeof value !== "string") { + return value; + } + + const normalized = value.trim().toLowerCase(); + if (normalized in LEGACY_FONT_WEIGHT_MAP) { + return LEGACY_FONT_WEIGHT_MAP[ + normalized as keyof typeof LEGACY_FONT_WEIGHT_MAP + ]; + } + + if (VALID_NUMERIC_FONT_WEIGHTS.has(normalized)) { + return normalized; + } + + return value; +} + +export { getProjectId } from "./utils"; + +function isV4Project({ project }: { project: ProjectRecord }): boolean { + const versionValue = project.version; + return typeof versionValue === "number" && versionValue >= 4; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts b/apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts index 971e3510..243b130c 100644 --- a/apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts +++ b/apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts @@ -1,177 +1,177 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -const KNOWN_STICKER_PROVIDER_IDS = new Set([ - "icons", - "emoji", - "flags", - "shapes", -]); - -export function transformProjectV4ToV5({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - const projectId = getProjectId({ project }); - if (!projectId) { - return { project, skipped: true, reason: "no project id" }; - } - - if (isV5Project({ project })) { - return { project, skipped: true, reason: "already v5" }; - } - - const migratedProject = migrateProjectStickerElements({ project }); - - return { - project: { - ...migratedProject, - version: 5, - }, - skipped: false, - }; -} - -function migrateProjectStickerElements({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const scenesValue = project.scenes; - if (!Array.isArray(scenesValue)) { - return project; - } - - let hasSceneChanges = false; - const migratedScenes = scenesValue.map((scene) => { - const migratedScene = migrateSceneStickerElements({ scene }); - if (migratedScene !== scene) { - hasSceneChanges = true; - } - return migratedScene; - }); - - if (!hasSceneChanges) { - return project; - } - - return { - ...project, - scenes: migratedScenes, - }; -} - -function migrateSceneStickerElements({ scene }: { scene: unknown }): unknown { - if (!isRecord(scene)) { - return scene; - } - - const tracksValue = scene.tracks; - if (!Array.isArray(tracksValue)) { - return scene; - } - - let hasTrackChanges = false; - const migratedTracks = tracksValue.map((track) => { - const migratedTrack = migrateTrackStickerElements({ track }); - if (migratedTrack !== track) { - hasTrackChanges = true; - } - return migratedTrack; - }); - - if (!hasTrackChanges) { - return scene; - } - - return { - ...scene, - tracks: migratedTracks, - }; -} - -function migrateTrackStickerElements({ track }: { track: unknown }): unknown { - if (!isRecord(track)) { - return track; - } - - const elementsValue = track.elements; - if (!Array.isArray(elementsValue)) { - return track; - } - - let hasElementChanges = false; - const migratedElements = elementsValue.map((element) => { - const migratedElement = migrateStickerElement({ element }); - if (migratedElement !== element) { - hasElementChanges = true; - } - return migratedElement; - }); - - if (!hasElementChanges) { - return track; - } - - return { - ...track, - elements: migratedElements, - }; -} - -function migrateStickerElement({ element }: { element: unknown }): unknown { - if (!isRecord(element) || element.type !== "sticker") { - return element; - } - - const existingStickerId = - typeof element.stickerId === "string" ? element.stickerId : null; - const legacyIconName = - typeof element.iconName === "string" ? element.iconName : null; - - const normalizedStickerId = - normalizeStickerId({ - value: existingStickerId ?? legacyIconName, - }) ?? null; - - const hasLegacyIconName = "iconName" in element; - const hasLegacyColor = "color" in element; - const shouldUpdateStickerId = normalizedStickerId !== existingStickerId; - - if (!hasLegacyIconName && !hasLegacyColor && !shouldUpdateStickerId) { - return element; - } - - const { - iconName: _legacyIconName, - color: _legacyColor, - ...remaining - } = element; - return normalizedStickerId - ? { ...remaining, stickerId: normalizedStickerId } - : remaining; -} - -function normalizeStickerId({ value }: { value: unknown }): string | null { - if (typeof value !== "string" || value.length === 0) { - return null; - } - - const separatorIndex = value.indexOf(":"); - if (separatorIndex === -1) { - return `icons:${value}`; - } - - const maybeProvider = value.slice(0, separatorIndex); - if (KNOWN_STICKER_PROVIDER_IDS.has(maybeProvider)) { - return value; - } - - return `icons:${value}`; -} - -function isV5Project({ project }: { project: ProjectRecord }): boolean { - const versionValue = project.version; - return typeof versionValue === "number" && versionValue >= 5; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +const KNOWN_STICKER_PROVIDER_IDS = new Set([ + "icons", + "emoji", + "flags", + "shapes", +]); + +export function transformProjectV4ToV5({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV5Project({ project })) { + return { project, skipped: true, reason: "already v5" }; + } + + const migratedProject = migrateProjectStickerElements({ project }); + + return { + project: { + ...migratedProject, + version: 5, + }, + skipped: false, + }; +} + +function migrateProjectStickerElements({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) { + return project; + } + + let hasSceneChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migratedScene = migrateSceneStickerElements({ scene }); + if (migratedScene !== scene) { + hasSceneChanges = true; + } + return migratedScene; + }); + + if (!hasSceneChanges) { + return project; + } + + return { + ...project, + scenes: migratedScenes, + }; +} + +function migrateSceneStickerElements({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) { + return scene; + } + + let hasTrackChanges = false; + const migratedTracks = tracksValue.map((track) => { + const migratedTrack = migrateTrackStickerElements({ track }); + if (migratedTrack !== track) { + hasTrackChanges = true; + } + return migratedTrack; + }); + + if (!hasTrackChanges) { + return scene; + } + + return { + ...scene, + tracks: migratedTracks, + }; +} + +function migrateTrackStickerElements({ track }: { track: unknown }): unknown { + if (!isRecord(track)) { + return track; + } + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) { + return track; + } + + let hasElementChanges = false; + const migratedElements = elementsValue.map((element) => { + const migratedElement = migrateStickerElement({ element }); + if (migratedElement !== element) { + hasElementChanges = true; + } + return migratedElement; + }); + + if (!hasElementChanges) { + return track; + } + + return { + ...track, + elements: migratedElements, + }; +} + +function migrateStickerElement({ element }: { element: unknown }): unknown { + if (!isRecord(element) || element.type !== "sticker") { + return element; + } + + const existingStickerId = + typeof element.stickerId === "string" ? element.stickerId : null; + const legacyIconName = + typeof element.iconName === "string" ? element.iconName : null; + + const normalizedStickerId = + normalizeStickerId({ + value: existingStickerId ?? legacyIconName, + }) ?? null; + + const hasLegacyIconName = "iconName" in element; + const hasLegacyColor = "color" in element; + const shouldUpdateStickerId = normalizedStickerId !== existingStickerId; + + if (!hasLegacyIconName && !hasLegacyColor && !shouldUpdateStickerId) { + return element; + } + + const { + iconName: _legacyIconName, + color: _legacyColor, + ...remaining + } = element; + return normalizedStickerId + ? { ...remaining, stickerId: normalizedStickerId } + : remaining; +} + +function normalizeStickerId({ value }: { value: unknown }): string | null { + if (typeof value !== "string" || value.length === 0) { + return null; + } + + const separatorIndex = value.indexOf(":"); + if (separatorIndex === -1) { + return `icons:${value}`; + } + + const maybeProvider = value.slice(0, separatorIndex); + if (KNOWN_STICKER_PROVIDER_IDS.has(maybeProvider)) { + return value; + } + + return `icons:${value}`; +} + +function isV5Project({ project }: { project: ProjectRecord }): boolean { + const versionValue = project.version; + return typeof versionValue === "number" && versionValue >= 5; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts b/apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts index ca855149..f3890f24 100644 --- a/apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts +++ b/apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts @@ -1,88 +1,88 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV5ToV6({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - const projectId = getProjectId({ project }); - if (!projectId) { - return { project, skipped: true, reason: "no project id" }; - } - - if (isV6Project({ project })) { - return { project, skipped: true, reason: "already v6" }; - } - - const migratedProject = migrateProjectBookmarks({ project }); - - return { - project: { - ...migratedProject, - version: 6, - }, - skipped: false, - }; -} - -function migrateProjectBookmarks({ - project, -}: { - project: ProjectRecord; -}): ProjectRecord { - const scenesValue = project.scenes; - if (!Array.isArray(scenesValue)) { - return project; - } - - let hasSceneChanges = false; - const migratedScenes = scenesValue.map((scene) => { - const migratedScene = migrateSceneBookmarks({ scene }); - if (migratedScene !== scene) { - hasSceneChanges = true; - } - return migratedScene; - }); - - if (!hasSceneChanges) { - return project; - } - - return { - ...project, - scenes: migratedScenes, - }; -} - -function migrateSceneBookmarks({ scene }: { scene: unknown }): unknown { - if (!isRecord(scene)) { - return scene; - } - - const bookmarksValue = scene.bookmarks; - if (!Array.isArray(bookmarksValue)) { - return scene; - } - - const needsMigration = bookmarksValue.some( - (bookmark) => typeof bookmark === "number", - ); - if (!needsMigration) { - return scene; - } - - const migratedBookmarks = bookmarksValue.map((bookmark) => - typeof bookmark === "number" ? { time: bookmark } : bookmark, - ); - - return { - ...scene, - bookmarks: migratedBookmarks, - }; -} - -function isV6Project({ project }: { project: ProjectRecord }): boolean { - const versionValue = project.version; - return typeof versionValue === "number" && versionValue >= 6; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV5ToV6({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV6Project({ project })) { + return { project, skipped: true, reason: "already v6" }; + } + + const migratedProject = migrateProjectBookmarks({ project }); + + return { + project: { + ...migratedProject, + version: 6, + }, + skipped: false, + }; +} + +function migrateProjectBookmarks({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) { + return project; + } + + let hasSceneChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migratedScene = migrateSceneBookmarks({ scene }); + if (migratedScene !== scene) { + hasSceneChanges = true; + } + return migratedScene; + }); + + if (!hasSceneChanges) { + return project; + } + + return { + ...project, + scenes: migratedScenes, + }; +} + +function migrateSceneBookmarks({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const bookmarksValue = scene.bookmarks; + if (!Array.isArray(bookmarksValue)) { + return scene; + } + + const needsMigration = bookmarksValue.some( + (bookmark) => typeof bookmark === "number", + ); + if (!needsMigration) { + return scene; + } + + const migratedBookmarks = bookmarksValue.map((bookmark) => + typeof bookmark === "number" ? { time: bookmark } : bookmark, + ); + + return { + ...scene, + bookmarks: migratedBookmarks, + }; +} + +function isV6Project({ project }: { project: ProjectRecord }): boolean { + const versionValue = project.version; + return typeof versionValue === "number" && versionValue >= 6; +} 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 index 6f4848d4..fe3b75fd 100644 --- a/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts +++ b/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts @@ -1,106 +1,106 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV6ToV7({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - 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; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV6ToV7({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + 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 index 7393f776..e592cde9 100644 --- a/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts +++ b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts @@ -1,96 +1,96 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV7ToV8({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - 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; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV7ToV8({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + 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/transformers/v8-to-v9.ts b/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts index 1dc895bc..628eebb6 100644 --- a/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts +++ b/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts @@ -1,99 +1,99 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId, isRecord } from "./utils"; - -export function transformProjectV8ToV9({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - const projectId = getProjectId({ project }); - if (!projectId) { - return { project, skipped: true, reason: "no project id" }; - } - - if (isV9Project({ project })) { - return { project, skipped: true, reason: "already v9" }; - } - - const migratedProject = migrateProjectTextElements({ project }); - - return { - project: { ...migratedProject, version: 9 }, - 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; - if (track.type !== "text") 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; - - const bg = element.background; - if (!isRecord(bg)) return element; - if (typeof bg.enabled === "boolean") return element; - - const color = typeof bg.color === "string" ? bg.color : "transparent"; - const enabled = color !== "transparent"; - - return { - ...element, - background: { ...bg, enabled }, - }; -} - -function isV9Project({ project }: { project: ProjectRecord }): boolean { - return typeof project.version === "number" && project.version >= 9; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV8ToV9({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV9Project({ project })) { + return { project, skipped: true, reason: "already v9" }; + } + + const migratedProject = migrateProjectTextElements({ project }); + + return { + project: { ...migratedProject, version: 9 }, + 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; + if (track.type !== "text") 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; + + const bg = element.background; + if (!isRecord(bg)) return element; + if (typeof bg.enabled === "boolean") return element; + + const color = typeof bg.color === "string" ? bg.color : "transparent"; + const enabled = color !== "transparent"; + + return { + ...element, + background: { ...bg, enabled }, + }; +} + +function isV9Project({ project }: { project: ProjectRecord }): boolean { + return typeof project.version === "number" && project.version >= 9; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts b/apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts index 52d13295..18885b62 100644 --- a/apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts +++ b/apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts @@ -1,21 +1,21 @@ -import type { MigrationResult, ProjectRecord } from "./types"; -import { getProjectId } from "./utils"; - -export function transformProjectV9ToV10({ - project, -}: { - project: ProjectRecord; -}): MigrationResult { - if (!getProjectId({ project })) { - return { project, skipped: true, reason: "no project id" }; - } - - if (typeof project.version === "number" && project.version >= 10) { - return { project, skipped: true, reason: "already v10" }; - } - - return { - project: { ...project, version: 10 }, - skipped: false, - }; -} +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId } from "./utils"; + +export function transformProjectV9ToV10({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + if (typeof project.version === "number" && project.version >= 10) { + return { project, skipped: true, reason: "already v10" }; + } + + return { + project: { ...project, version: 10 }, + skipped: false, + }; +} diff --git a/apps/web/src/services/storage/migrations/v10-to-v11.ts b/apps/web/src/services/storage/migrations/v10-to-v11.ts index 68439fbe..345dbeb3 100644 --- a/apps/web/src/services/storage/migrations/v10-to-v11.ts +++ b/apps/web/src/services/storage/migrations/v10-to-v11.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV10ToV11 } from "./transformers/v10-to-v11"; - -export class V10toV11Migration extends StorageMigration { - from = 10; - to = 11; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV10ToV11({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV10ToV11 } from "./transformers/v10-to-v11"; + +export class V10toV11Migration extends StorageMigration { + from = 10; + to = 11; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV10ToV11({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v11-to-v12.ts b/apps/web/src/services/storage/migrations/v11-to-v12.ts index 5efedbef..b3154ca8 100644 --- a/apps/web/src/services/storage/migrations/v11-to-v12.ts +++ b/apps/web/src/services/storage/migrations/v11-to-v12.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV11ToV12 } from "./transformers/v11-to-v12"; - -export class V11toV12Migration extends StorageMigration { - from = 11; - to = 12; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV11ToV12({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV11ToV12 } from "./transformers/v11-to-v12"; + +export class V11toV12Migration extends StorageMigration { + from = 11; + to = 12; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV11ToV12({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v12-to-v13.ts b/apps/web/src/services/storage/migrations/v12-to-v13.ts index 5c6e05fc..34b8de60 100644 --- a/apps/web/src/services/storage/migrations/v12-to-v13.ts +++ b/apps/web/src/services/storage/migrations/v12-to-v13.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV12ToV13 } from "./transformers/v12-to-v13"; - -export class V12toV13Migration extends StorageMigration { - from = 12; - to = 13; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV12ToV13({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV12ToV13 } from "./transformers/v12-to-v13"; + +export class V12toV13Migration extends StorageMigration { + from = 12; + to = 13; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV12ToV13({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v13-to-v14.ts b/apps/web/src/services/storage/migrations/v13-to-v14.ts index fbc9fc5f..b8bde59c 100644 --- a/apps/web/src/services/storage/migrations/v13-to-v14.ts +++ b/apps/web/src/services/storage/migrations/v13-to-v14.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV13ToV14 } from "./transformers/v13-to-v14"; - -export class V13toV14Migration extends StorageMigration { - from = 13; - to = 14; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV13ToV14({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV13ToV14 } from "./transformers/v13-to-v14"; + +export class V13toV14Migration extends StorageMigration { + from = 13; + to = 14; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV13ToV14({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v14-to-v15.ts b/apps/web/src/services/storage/migrations/v14-to-v15.ts index 1d18423f..5c3a60cf 100644 --- a/apps/web/src/services/storage/migrations/v14-to-v15.ts +++ b/apps/web/src/services/storage/migrations/v14-to-v15.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV14ToV15 } from "./transformers/v14-to-v15"; - -export class V14toV15Migration extends StorageMigration { - from = 14; - to = 15; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV14ToV15({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV14ToV15 } from "./transformers/v14-to-v15"; + +export class V14toV15Migration extends StorageMigration { + from = 14; + to = 15; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV14ToV15({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v15-to-v16.ts b/apps/web/src/services/storage/migrations/v15-to-v16.ts index 800c9c07..07afe148 100644 --- a/apps/web/src/services/storage/migrations/v15-to-v16.ts +++ b/apps/web/src/services/storage/migrations/v15-to-v16.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV15ToV16 } from "./transformers/v15-to-v16"; - -export class V15toV16Migration extends StorageMigration { - from = 15; - to = 16; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV15ToV16({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV15ToV16 } from "./transformers/v15-to-v16"; + +export class V15toV16Migration extends StorageMigration { + from = 15; + to = 16; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV15ToV16({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v16-to-v17.ts b/apps/web/src/services/storage/migrations/v16-to-v17.ts index f4029db6..053e8d07 100644 --- a/apps/web/src/services/storage/migrations/v16-to-v17.ts +++ b/apps/web/src/services/storage/migrations/v16-to-v17.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV16ToV17 } from "./transformers/v16-to-v17"; - -export class V16toV17Migration extends StorageMigration { - from = 16; - to = 17; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV16ToV17({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV16ToV17 } from "./transformers/v16-to-v17"; + +export class V16toV17Migration extends StorageMigration { + from = 16; + to = 17; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV16ToV17({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v17-to-v18.ts b/apps/web/src/services/storage/migrations/v17-to-v18.ts index 75beb36b..b1591907 100644 --- a/apps/web/src/services/storage/migrations/v17-to-v18.ts +++ b/apps/web/src/services/storage/migrations/v17-to-v18.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV17ToV18 } from "./transformers/v17-to-v18"; - -export class V17toV18Migration extends StorageMigration { - from = 17; - to = 18; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV17ToV18({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV17ToV18 } from "./transformers/v17-to-v18"; + +export class V17toV18Migration extends StorageMigration { + from = 17; + to = 18; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV17ToV18({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v18-to-v19.ts b/apps/web/src/services/storage/migrations/v18-to-v19.ts index 166d9855..d8fde783 100644 --- a/apps/web/src/services/storage/migrations/v18-to-v19.ts +++ b/apps/web/src/services/storage/migrations/v18-to-v19.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV18ToV19 } from "./transformers/v18-to-v19"; - -export class V18toV19Migration extends StorageMigration { - from = 18; - to = 19; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV18ToV19({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV18ToV19 } from "./transformers/v18-to-v19"; + +export class V18toV19Migration extends StorageMigration { + from = 18; + to = 19; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV18ToV19({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v3-to-v4.ts b/apps/web/src/services/storage/migrations/v3-to-v4.ts index 67b2fdf7..a3738076 100644 --- a/apps/web/src/services/storage/migrations/v3-to-v4.ts +++ b/apps/web/src/services/storage/migrations/v3-to-v4.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV3ToV4 } from "./transformers/v3-to-v4"; - -export class V3toV4Migration extends StorageMigration { - from = 3; - to = 4; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV3ToV4({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV3ToV4 } from "./transformers/v3-to-v4"; + +export class V3toV4Migration extends StorageMigration { + from = 3; + to = 4; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV3ToV4({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v4-to-v5.ts b/apps/web/src/services/storage/migrations/v4-to-v5.ts index 829a746d..394406d1 100644 --- a/apps/web/src/services/storage/migrations/v4-to-v5.ts +++ b/apps/web/src/services/storage/migrations/v4-to-v5.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV4ToV5 } from "./transformers/v4-to-v5"; - -export class V4toV5Migration extends StorageMigration { - from = 4; - to = 5; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV4ToV5({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV4ToV5 } from "./transformers/v4-to-v5"; + +export class V4toV5Migration extends StorageMigration { + from = 4; + to = 5; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV4ToV5({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v5-to-v6.ts b/apps/web/src/services/storage/migrations/v5-to-v6.ts index b621bd72..e6be4f9b 100644 --- a/apps/web/src/services/storage/migrations/v5-to-v6.ts +++ b/apps/web/src/services/storage/migrations/v5-to-v6.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV5ToV6 } from "./transformers/v5-to-v6"; - -export class V5toV6Migration extends StorageMigration { - from = 5; - to = 6; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV5ToV6({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV5ToV6 } from "./transformers/v5-to-v6"; + +export class V5toV6Migration extends StorageMigration { + from = 5; + to = 6; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV5ToV6({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v6-to-v7.ts b/apps/web/src/services/storage/migrations/v6-to-v7.ts index 0556b4d5..3f63770b 100644 --- a/apps/web/src/services/storage/migrations/v6-to-v7.ts +++ b/apps/web/src/services/storage/migrations/v6-to-v7.ts @@ -1,16 +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 }); - } -} +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 index 33372c0a..5a3302f7 100644 --- a/apps/web/src/services/storage/migrations/v7-to-v8.ts +++ b/apps/web/src/services/storage/migrations/v7-to-v8.ts @@ -1,16 +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 }); - } -} +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 }); + } +} diff --git a/apps/web/src/services/storage/migrations/v8-to-v9.ts b/apps/web/src/services/storage/migrations/v8-to-v9.ts index 6222eeac..bdbed910 100644 --- a/apps/web/src/services/storage/migrations/v8-to-v9.ts +++ b/apps/web/src/services/storage/migrations/v8-to-v9.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV8ToV9 } from "./transformers/v8-to-v9"; - -export class V8toV9Migration extends StorageMigration { - from = 8; - to = 9; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV8ToV9({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV8ToV9 } from "./transformers/v8-to-v9"; + +export class V8toV9Migration extends StorageMigration { + from = 8; + to = 9; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV8ToV9({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v9-to-v10.ts b/apps/web/src/services/storage/migrations/v9-to-v10.ts index 801096fe..d61116f0 100644 --- a/apps/web/src/services/storage/migrations/v9-to-v10.ts +++ b/apps/web/src/services/storage/migrations/v9-to-v10.ts @@ -1,16 +1,16 @@ -import { StorageMigration } from "./base"; -import type { ProjectRecord } from "./transformers/types"; -import { transformProjectV9ToV10 } from "./transformers/v9-to-v10"; - -export class V9toV10Migration extends StorageMigration { - from = 9; - to = 10; - - async transform(project: ProjectRecord): Promise<{ - project: ProjectRecord; - skipped: boolean; - reason?: string; - }> { - return transformProjectV9ToV10({ project }); - } -} +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV9ToV10 } from "./transformers/v9-to-v10"; + +export class V9toV10Migration extends StorageMigration { + from = 9; + to = 10; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV9ToV10({ project }); + } +} diff --git a/apps/web/src/services/storage/quota.ts b/apps/web/src/services/storage/quota.ts index 1eb6028e..4d1adad5 100644 --- a/apps/web/src/services/storage/quota.ts +++ b/apps/web/src/services/storage/quota.ts @@ -1,145 +1,145 @@ -const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const; - -export const STORAGE_HEADROOM_RESERVE_BYTES = 50 * 1024 * 1024; - -export interface StorageQuotaStatus { - quotaBytes: number | null; - usageBytes: number | null; - headroomBytes: number | null; - availableBytes: number | null; -} - -export interface StorageCapacityCheckResult { - canStore: boolean; - reason: "enough-space" | "insufficient-space" | "estimate-unavailable"; - availableBytes: number | null; -} - -function normalizeByteValue({ value }: { value: unknown }): number | null { - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { - return null; - } - - return value; -} - -export function formatStorageBytes({ bytes }: { bytes: number }): string { - if (!Number.isFinite(bytes) || bytes <= 0) { - return "0 B"; - } - - let value = bytes; - let unitIndex = 0; - - while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) { - value /= 1024; - unitIndex += 1; - } - - const precision = value >= 10 || unitIndex === 0 ? 0 : 1; - return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`; -} - -export async function readStorageQuotaStatus(): Promise { - if ( - typeof navigator === "undefined" || - !navigator.storage || - typeof navigator.storage.estimate !== "function" - ) { - return { - quotaBytes: null, - usageBytes: null, - headroomBytes: null, - availableBytes: null, - }; - } - - const estimate = await navigator.storage.estimate(); - const quotaBytes = normalizeByteValue({ value: estimate.quota }); - const usageBytes = normalizeByteValue({ value: estimate.usage }); - - if (quotaBytes === null || usageBytes === null) { - return { - quotaBytes, - usageBytes, - headroomBytes: null, - availableBytes: null, - }; - } - - const headroomBytes = Math.max(quotaBytes - usageBytes, 0); - const availableBytes = Math.max( - headroomBytes - STORAGE_HEADROOM_RESERVE_BYTES, - 0, - ); - - return { - quotaBytes, - usageBytes, - headroomBytes, - availableBytes, - }; -} - -export function evaluateStorageCapacity({ - requiredBytes, - quotaStatus, -}: { - requiredBytes: number; - quotaStatus: StorageQuotaStatus; -}): StorageCapacityCheckResult { - if (quotaStatus.availableBytes === null) { - return { - canStore: true, - reason: "estimate-unavailable", - availableBytes: null, - }; - } - - if (requiredBytes > quotaStatus.availableBytes) { - return { - canStore: false, - reason: "insufficient-space", - availableBytes: quotaStatus.availableBytes, - }; - } - - return { - canStore: true, - reason: "enough-space", - availableBytes: quotaStatus.availableBytes, - }; -} - -export class StorageQuotaExceededError extends Error { - requiredBytes: number; - - constructor({ requiredBytes }: { requiredBytes: number }) { - super( - `Not enough browser storage to save a ${formatStorageBytes({ bytes: requiredBytes })} file.`, - ); - - this.name = "StorageQuotaExceededError"; - this.requiredBytes = requiredBytes; - } -} - -export function isStorageQuotaExceededError({ - error, -}: { - error: unknown; -}): boolean { - if (error instanceof StorageQuotaExceededError) { - return true; - } - - if (!(error instanceof Error)) { - return false; - } - - return ( - error.name === "QuotaExceededError" || - error.name === "NS_ERROR_DOM_QUOTA_REACHED" || - error.message.toLowerCase().includes("quota") - ); -} +const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const; + +export const STORAGE_HEADROOM_RESERVE_BYTES = 50 * 1024 * 1024; + +export interface StorageQuotaStatus { + quotaBytes: number | null; + usageBytes: number | null; + headroomBytes: number | null; + availableBytes: number | null; +} + +export interface StorageCapacityCheckResult { + canStore: boolean; + reason: "enough-space" | "insufficient-space" | "estimate-unavailable"; + availableBytes: number | null; +} + +function normalizeByteValue({ value }: { value: unknown }): number | null { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return null; + } + + return value; +} + +export function formatStorageBytes({ bytes }: { bytes: number }): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return "0 B"; + } + + let value = bytes; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) { + value /= 1024; + unitIndex += 1; + } + + const precision = value >= 10 || unitIndex === 0 ? 0 : 1; + return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`; +} + +export async function readStorageQuotaStatus(): Promise { + if ( + typeof navigator === "undefined" || + !navigator.storage || + typeof navigator.storage.estimate !== "function" + ) { + return { + quotaBytes: null, + usageBytes: null, + headroomBytes: null, + availableBytes: null, + }; + } + + const estimate = await navigator.storage.estimate(); + const quotaBytes = normalizeByteValue({ value: estimate.quota }); + const usageBytes = normalizeByteValue({ value: estimate.usage }); + + if (quotaBytes === null || usageBytes === null) { + return { + quotaBytes, + usageBytes, + headroomBytes: null, + availableBytes: null, + }; + } + + const headroomBytes = Math.max(quotaBytes - usageBytes, 0); + const availableBytes = Math.max( + headroomBytes - STORAGE_HEADROOM_RESERVE_BYTES, + 0, + ); + + return { + quotaBytes, + usageBytes, + headroomBytes, + availableBytes, + }; +} + +export function evaluateStorageCapacity({ + requiredBytes, + quotaStatus, +}: { + requiredBytes: number; + quotaStatus: StorageQuotaStatus; +}): StorageCapacityCheckResult { + if (quotaStatus.availableBytes === null) { + return { + canStore: true, + reason: "estimate-unavailable", + availableBytes: null, + }; + } + + if (requiredBytes > quotaStatus.availableBytes) { + return { + canStore: false, + reason: "insufficient-space", + availableBytes: quotaStatus.availableBytes, + }; + } + + return { + canStore: true, + reason: "enough-space", + availableBytes: quotaStatus.availableBytes, + }; +} + +export class StorageQuotaExceededError extends Error { + requiredBytes: number; + + constructor({ requiredBytes }: { requiredBytes: number }) { + super( + `Not enough browser storage to save a ${formatStorageBytes({ bytes: requiredBytes })} file.`, + ); + + this.name = "StorageQuotaExceededError"; + this.requiredBytes = requiredBytes; + } +} + +export function isStorageQuotaExceededError({ + error, +}: { + error: unknown; +}): boolean { + if (error instanceof StorageQuotaExceededError) { + return true; + } + + if (!(error instanceof Error)) { + return false; + } + + return ( + error.name === "QuotaExceededError" || + error.name === "NS_ERROR_DOM_QUOTA_REACHED" || + error.message.toLowerCase().includes("quota") + ); +} diff --git a/apps/web/src/services/transcription/service.ts b/apps/web/src/services/transcription/service.ts index ab88106c..fa657032 100644 --- a/apps/web/src/services/transcription/service.ts +++ b/apps/web/src/services/transcription/service.ts @@ -1,186 +1,186 @@ -import type { - TranscriptionLanguage, - TranscriptionResult, - TranscriptionProgress, - TranscriptionModelId, -} from "@/lib/transcription/types"; -import { - DEFAULT_TRANSCRIPTION_MODEL, - TRANSCRIPTION_MODELS, -} from "@/constants/transcription-constants"; -import type { WorkerMessage, WorkerResponse } from "./worker"; - -type ProgressCallback = (progress: TranscriptionProgress) => void; - -class TranscriptionService { - private worker: Worker | null = null; - private currentModelId: TranscriptionModelId | null = null; - private isInitialized = false; - private isInitializing = false; - - async transcribe({ - audioData, - language = "auto", - modelId = DEFAULT_TRANSCRIPTION_MODEL, - onProgress, - }: { - audioData: Float32Array; - language?: TranscriptionLanguage; - modelId?: TranscriptionModelId; - onProgress?: ProgressCallback; - }): Promise { - await this.ensureWorker({ modelId, onProgress }); - - return new Promise((resolve, reject) => { - if (!this.worker) { - reject(new Error("Worker not initialized")); - return; - } - - const handleMessage = (event: MessageEvent) => { - const response = event.data; - - switch (response.type) { - case "transcribe-progress": - onProgress?.({ - status: "transcribing", - progress: response.progress, - message: "Transcribing audio...", - }); - break; - - case "transcribe-complete": - this.worker?.removeEventListener("message", handleMessage); - resolve({ - text: response.text, - segments: response.segments, - language, - }); - break; - - case "transcribe-error": - this.worker?.removeEventListener("message", handleMessage); - reject(new Error(response.error)); - break; - - case "cancelled": - this.worker?.removeEventListener("message", handleMessage); - reject(new Error("Transcription cancelled")); - break; - } - }; - - this.worker.addEventListener("message", handleMessage); - - this.worker.postMessage({ - type: "transcribe", - audio: audioData, - language, - } satisfies WorkerMessage); - }); - } - - cancel() { - this.worker?.postMessage({ type: "cancel" } satisfies WorkerMessage); - } - - private async ensureWorker({ - modelId, - onProgress, - }: { - modelId: TranscriptionModelId; - onProgress?: ProgressCallback; - }): Promise { - const needsNewModel = this.currentModelId !== modelId; - - if (this.worker && this.isInitialized && !needsNewModel) { - return; - } - - if (this.isInitializing && !needsNewModel) { - await this.waitForInit(); - return; - } - - this.terminate(); - this.isInitializing = true; - this.isInitialized = false; - - const model = TRANSCRIPTION_MODELS.find((m) => m.id === modelId); - if (!model) { - throw new Error(`Unknown model: ${modelId}`); - } - - this.worker = new Worker(new URL("./worker.ts", import.meta.url), { - type: "module", - }); - - return new Promise((resolve, reject) => { - if (!this.worker) { - reject(new Error("Failed to create worker")); - return; - } - - const handleMessage = (event: MessageEvent) => { - const response = event.data; - - switch (response.type) { - case "init-progress": - onProgress?.({ - status: "loading-model", - progress: response.progress, - message: `Loading ${model.name} model...`, - }); - break; - - case "init-complete": - this.worker?.removeEventListener("message", handleMessage); - this.isInitialized = true; - this.isInitializing = false; - this.currentModelId = modelId; - resolve(); - break; - - case "init-error": - this.worker?.removeEventListener("message", handleMessage); - this.isInitializing = false; - this.terminate(); - reject(new Error(response.error)); - break; - } - }; - - this.worker.addEventListener("message", handleMessage); - - this.worker.postMessage({ - type: "init", - modelId: model.huggingFaceId, - } satisfies WorkerMessage); - }); - } - - private waitForInit(): Promise { - return new Promise((resolve) => { - const checkInit = () => { - if (this.isInitialized) { - resolve(); - } else if (!this.isInitializing) { - resolve(); - } else { - setTimeout(checkInit, 100); - } - }; - checkInit(); - }); - } - - terminate() { - this.worker?.terminate(); - this.worker = null; - this.isInitialized = false; - this.isInitializing = false; - this.currentModelId = null; - } -} - -export const transcriptionService = new TranscriptionService(); +import type { + TranscriptionLanguage, + TranscriptionResult, + TranscriptionProgress, + TranscriptionModelId, +} from "@/lib/transcription/types"; +import { + DEFAULT_TRANSCRIPTION_MODEL, + TRANSCRIPTION_MODELS, +} from "@/constants/transcription-constants"; +import type { WorkerMessage, WorkerResponse } from "./worker"; + +type ProgressCallback = (progress: TranscriptionProgress) => void; + +class TranscriptionService { + private worker: Worker | null = null; + private currentModelId: TranscriptionModelId | null = null; + private isInitialized = false; + private isInitializing = false; + + async transcribe({ + audioData, + language = "auto", + modelId = DEFAULT_TRANSCRIPTION_MODEL, + onProgress, + }: { + audioData: Float32Array; + language?: TranscriptionLanguage; + modelId?: TranscriptionModelId; + onProgress?: ProgressCallback; + }): Promise { + await this.ensureWorker({ modelId, onProgress }); + + return new Promise((resolve, reject) => { + if (!this.worker) { + reject(new Error("Worker not initialized")); + return; + } + + const handleMessage = (event: MessageEvent) => { + const response = event.data; + + switch (response.type) { + case "transcribe-progress": + onProgress?.({ + status: "transcribing", + progress: response.progress, + message: "Transcribing audio...", + }); + break; + + case "transcribe-complete": + this.worker?.removeEventListener("message", handleMessage); + resolve({ + text: response.text, + segments: response.segments, + language, + }); + break; + + case "transcribe-error": + this.worker?.removeEventListener("message", handleMessage); + reject(new Error(response.error)); + break; + + case "cancelled": + this.worker?.removeEventListener("message", handleMessage); + reject(new Error("Transcription cancelled")); + break; + } + }; + + this.worker.addEventListener("message", handleMessage); + + this.worker.postMessage({ + type: "transcribe", + audio: audioData, + language, + } satisfies WorkerMessage); + }); + } + + cancel() { + this.worker?.postMessage({ type: "cancel" } satisfies WorkerMessage); + } + + private async ensureWorker({ + modelId, + onProgress, + }: { + modelId: TranscriptionModelId; + onProgress?: ProgressCallback; + }): Promise { + const needsNewModel = this.currentModelId !== modelId; + + if (this.worker && this.isInitialized && !needsNewModel) { + return; + } + + if (this.isInitializing && !needsNewModel) { + await this.waitForInit(); + return; + } + + this.terminate(); + this.isInitializing = true; + this.isInitialized = false; + + const model = TRANSCRIPTION_MODELS.find((m) => m.id === modelId); + if (!model) { + throw new Error(`Unknown model: ${modelId}`); + } + + this.worker = new Worker(new URL("./worker.ts", import.meta.url), { + type: "module", + }); + + return new Promise((resolve, reject) => { + if (!this.worker) { + reject(new Error("Failed to create worker")); + return; + } + + const handleMessage = (event: MessageEvent) => { + const response = event.data; + + switch (response.type) { + case "init-progress": + onProgress?.({ + status: "loading-model", + progress: response.progress, + message: `Loading ${model.name} model...`, + }); + break; + + case "init-complete": + this.worker?.removeEventListener("message", handleMessage); + this.isInitialized = true; + this.isInitializing = false; + this.currentModelId = modelId; + resolve(); + break; + + case "init-error": + this.worker?.removeEventListener("message", handleMessage); + this.isInitializing = false; + this.terminate(); + reject(new Error(response.error)); + break; + } + }; + + this.worker.addEventListener("message", handleMessage); + + this.worker.postMessage({ + type: "init", + modelId: model.huggingFaceId, + } satisfies WorkerMessage); + }); + } + + private waitForInit(): Promise { + return new Promise((resolve) => { + const checkInit = () => { + if (this.isInitialized) { + resolve(); + } else if (!this.isInitializing) { + resolve(); + } else { + setTimeout(checkInit, 100); + } + }; + checkInit(); + }); + } + + terminate() { + this.worker?.terminate(); + this.worker = null; + this.isInitialized = false; + this.isInitializing = false; + this.currentModelId = null; + } +} + +export const transcriptionService = new TranscriptionService(); diff --git a/apps/web/src/services/transcription/worker.ts b/apps/web/src/services/transcription/worker.ts index e583b2ab..e22fe796 100644 --- a/apps/web/src/services/transcription/worker.ts +++ b/apps/web/src/services/transcription/worker.ts @@ -1,176 +1,176 @@ -import { - pipeline, - type AutomaticSpeechRecognitionPipeline, - type AutomaticSpeechRecognitionOutput, -} from "@huggingface/transformers"; -import type { TranscriptionSegment } from "@/lib/transcription/types"; -import { - DEFAULT_CHUNK_LENGTH_SECONDS, - DEFAULT_STRIDE_SECONDS, -} from "@/constants/transcription-constants"; - -export type WorkerMessage = - | { type: "init"; modelId: string } - | { type: "transcribe"; audio: Float32Array; language: string } - | { type: "cancel" }; - -export type WorkerResponse = - | { type: "init-progress"; progress: number } - | { type: "init-complete" } - | { type: "init-error"; error: string } - | { type: "transcribe-progress"; progress: number } - | { - type: "transcribe-complete"; - text: string; - segments: TranscriptionSegment[]; - } - | { type: "transcribe-error"; error: string } - | { type: "cancelled" }; - -let transcriber: AutomaticSpeechRecognitionPipeline | null = null; -let cancelled = false; -let lastReportedProgress = -1; -const fileBytes = new Map(); - -self.onmessage = async (event: MessageEvent) => { - const message = event.data; - - switch (message.type) { - case "init": - await handleInit({ modelId: message.modelId }); - break; - case "transcribe": - await handleTranscribe({ - audio: message.audio, - language: message.language, - }); - break; - case "cancel": - cancelled = true; - self.postMessage({ type: "cancelled" } satisfies WorkerResponse); - break; - } -}; - -async function handleInit({ modelId }: { modelId: string }) { - lastReportedProgress = -1; - fileBytes.clear(); - - try { - transcriber = (await pipeline("automatic-speech-recognition", modelId, { - dtype: "q4", - device: "auto", - progress_callback: (progressInfo: { - status?: string; - file?: string; - loaded?: number; - total?: number; - }) => { - const file = progressInfo.file; - if (!file) return; - - const loaded = progressInfo.loaded ?? 0; - const total = progressInfo.total ?? 0; - - if (progressInfo.status === "progress" && total > 0) { - fileBytes.set(file, { loaded, total }); - } else if (progressInfo.status === "done") { - const existing = fileBytes.get(file); - if (existing) { - fileBytes.set(file, { - loaded: existing.total, - total: existing.total, - }); - } - } - - // sum all bytes - let totalLoaded = 0; - let totalSize = 0; - for (const { loaded, total } of fileBytes.values()) { - totalLoaded += loaded; - totalSize += total; - } - - if (totalSize === 0) return; - - const overallProgress = (totalLoaded / totalSize) * 100; - const roundedProgress = Math.floor(overallProgress); - - if (roundedProgress !== lastReportedProgress) { - lastReportedProgress = roundedProgress; - self.postMessage({ - type: "init-progress", - progress: roundedProgress, - } satisfies WorkerResponse); - } - }, - })) as unknown as AutomaticSpeechRecognitionPipeline; - - self.postMessage({ type: "init-complete" } satisfies WorkerResponse); - } catch (error) { - self.postMessage({ - type: "init-error", - error: error instanceof Error ? error.message : "Failed to load model", - } satisfies WorkerResponse); - } -} - -async function handleTranscribe({ - audio, - language, -}: { - audio: Float32Array; - language: string; -}) { - if (!transcriber) { - self.postMessage({ - type: "transcribe-error", - error: "Model not initialized", - } satisfies WorkerResponse); - return; - } - - cancelled = false; - - try { - const rawResult = await transcriber(audio, { - chunk_length_s: DEFAULT_CHUNK_LENGTH_SECONDS, - stride_length_s: DEFAULT_STRIDE_SECONDS, - language: language === "auto" ? undefined : language, - return_timestamps: true, - }); - - if (cancelled) return; - - const result: AutomaticSpeechRecognitionOutput = Array.isArray(rawResult) - ? rawResult[0] - : rawResult; - - const segments: TranscriptionSegment[] = []; - - if (result.chunks) { - for (const chunk of result.chunks) { - if (chunk.timestamp && chunk.timestamp.length >= 2) { - segments.push({ - text: chunk.text, - start: chunk.timestamp[0] ?? 0, - end: chunk.timestamp[1] ?? chunk.timestamp[0] ?? 0, - }); - } - } - } - - self.postMessage({ - type: "transcribe-complete", - text: result.text, - segments, - } satisfies WorkerResponse); - } catch (error) { - if (cancelled) return; - self.postMessage({ - type: "transcribe-error", - error: error instanceof Error ? error.message : "Transcription failed", - } satisfies WorkerResponse); - } -} +import { + pipeline, + type AutomaticSpeechRecognitionPipeline, + type AutomaticSpeechRecognitionOutput, +} from "@huggingface/transformers"; +import type { TranscriptionSegment } from "@/lib/transcription/types"; +import { + DEFAULT_CHUNK_LENGTH_SECONDS, + DEFAULT_STRIDE_SECONDS, +} from "@/constants/transcription-constants"; + +export type WorkerMessage = + | { type: "init"; modelId: string } + | { type: "transcribe"; audio: Float32Array; language: string } + | { type: "cancel" }; + +export type WorkerResponse = + | { type: "init-progress"; progress: number } + | { type: "init-complete" } + | { type: "init-error"; error: string } + | { type: "transcribe-progress"; progress: number } + | { + type: "transcribe-complete"; + text: string; + segments: TranscriptionSegment[]; + } + | { type: "transcribe-error"; error: string } + | { type: "cancelled" }; + +let transcriber: AutomaticSpeechRecognitionPipeline | null = null; +let cancelled = false; +let lastReportedProgress = -1; +const fileBytes = new Map(); + +self.onmessage = async (event: MessageEvent) => { + const message = event.data; + + switch (message.type) { + case "init": + await handleInit({ modelId: message.modelId }); + break; + case "transcribe": + await handleTranscribe({ + audio: message.audio, + language: message.language, + }); + break; + case "cancel": + cancelled = true; + self.postMessage({ type: "cancelled" } satisfies WorkerResponse); + break; + } +}; + +async function handleInit({ modelId }: { modelId: string }) { + lastReportedProgress = -1; + fileBytes.clear(); + + try { + transcriber = (await pipeline("automatic-speech-recognition", modelId, { + dtype: "q4", + device: "auto", + progress_callback: (progressInfo: { + status?: string; + file?: string; + loaded?: number; + total?: number; + }) => { + const file = progressInfo.file; + if (!file) return; + + const loaded = progressInfo.loaded ?? 0; + const total = progressInfo.total ?? 0; + + if (progressInfo.status === "progress" && total > 0) { + fileBytes.set(file, { loaded, total }); + } else if (progressInfo.status === "done") { + const existing = fileBytes.get(file); + if (existing) { + fileBytes.set(file, { + loaded: existing.total, + total: existing.total, + }); + } + } + + // sum all bytes + let totalLoaded = 0; + let totalSize = 0; + for (const { loaded, total } of fileBytes.values()) { + totalLoaded += loaded; + totalSize += total; + } + + if (totalSize === 0) return; + + const overallProgress = (totalLoaded / totalSize) * 100; + const roundedProgress = Math.floor(overallProgress); + + if (roundedProgress !== lastReportedProgress) { + lastReportedProgress = roundedProgress; + self.postMessage({ + type: "init-progress", + progress: roundedProgress, + } satisfies WorkerResponse); + } + }, + })) as unknown as AutomaticSpeechRecognitionPipeline; + + self.postMessage({ type: "init-complete" } satisfies WorkerResponse); + } catch (error) { + self.postMessage({ + type: "init-error", + error: error instanceof Error ? error.message : "Failed to load model", + } satisfies WorkerResponse); + } +} + +async function handleTranscribe({ + audio, + language, +}: { + audio: Float32Array; + language: string; +}) { + if (!transcriber) { + self.postMessage({ + type: "transcribe-error", + error: "Model not initialized", + } satisfies WorkerResponse); + return; + } + + cancelled = false; + + try { + const rawResult = await transcriber(audio, { + chunk_length_s: DEFAULT_CHUNK_LENGTH_SECONDS, + stride_length_s: DEFAULT_STRIDE_SECONDS, + language: language === "auto" ? undefined : language, + return_timestamps: true, + }); + + if (cancelled) return; + + const result: AutomaticSpeechRecognitionOutput = Array.isArray(rawResult) + ? rawResult[0] + : rawResult; + + const segments: TranscriptionSegment[] = []; + + if (result.chunks) { + for (const chunk of result.chunks) { + if (chunk.timestamp && chunk.timestamp.length >= 2) { + segments.push({ + text: chunk.text, + start: chunk.timestamp[0] ?? 0, + end: chunk.timestamp[1] ?? chunk.timestamp[0] ?? 0, + }); + } + } + } + + self.postMessage({ + type: "transcribe-complete", + text: result.text, + segments, + } satisfies WorkerResponse); + } catch (error) { + if (cancelled) return; + self.postMessage({ + type: "transcribe-error", + error: error instanceof Error ? error.message : "Transcription failed", + } satisfies WorkerResponse); + } +} -- cgit v1.3.1