summaryrefslogtreecommitdiff
path: root/apps/web/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services')
-rw-r--r--apps/web/src/services/renderer/canvas-utils.ts32
-rw-r--r--apps/web/src/services/renderer/mask-feather.ts108
-rw-r--r--apps/web/src/services/renderer/nodes/effect-layer-node.ts162
-rw-r--r--apps/web/src/services/renderer/nodes/graphic-node.ts184
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts74
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts544
-rw-r--r--apps/web/src/services/renderer/scene-exporter.ts326
-rw-r--r--apps/web/src/services/renderer/webgl/jfa.ts388
-rw-r--r--apps/web/src/services/renderer/webgl/webgl-context.ts98
-rw-r--r--apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts70
-rw-r--r--apps/web/src/services/renderer/webgl/webgl-utils.ts596
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts20
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts108
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts186
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts264
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts126
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts280
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts274
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts184
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts350
-rw-r--r--apps/web/src/services/storage/migrations/base.ts18
-rw-r--r--apps/web/src/services/storage/migrations/index.ts92
-rw-r--r--apps/web/src/services/storage/migrations/runner.ts310
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts226
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts392
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts204
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts212
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts134
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts120
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts214
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts210
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts92
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts378
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts354
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts176
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts212
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts192
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts198
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts42
-rw-r--r--apps/web/src/services/storage/migrations/v10-to-v11.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v11-to-v12.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v12-to-v13.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v13-to-v14.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v14-to-v15.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v15-to-v16.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v16-to-v17.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v17-to-v18.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v18-to-v19.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v3-to-v4.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v4-to-v5.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v5-to-v6.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v6-to-v7.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v7-to-v8.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v8-to-v9.ts32
-rw-r--r--apps/web/src/services/storage/migrations/v9-to-v10.ts32
-rw-r--r--apps/web/src/services/storage/quota.ts290
-rw-r--r--apps/web/src/services/transcription/service.ts372
-rw-r--r--apps/web/src/services/transcription/worker.ts352
58 files changed, 4838 insertions, 4838 deletions
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<EffectLayerNodeParams> {
- async render({
- renderer,
- time,
- }: {
- renderer: CanvasRenderer;
- time: number;
- }): Promise<void> {
- if (
- !isInRange({
- time,
- timeOffset: this.params.timeOffset,
- duration: this.params.duration,
- })
- ) {
- return;
- }
-
- const source = renderer.context.canvas as CanvasImageSource;
-
- const effectDefinition = 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<EffectLayerNodeParams> {
+ async render({
+ renderer,
+ time,
+ }: {
+ renderer: CanvasRenderer;
+ time: number;
+ }): Promise<void> {
+ if (
+ !isInRange({
+ time,
+ timeOffset: this.params.timeOffset,
+ duration: this.params.duration,
+ })
+ ) {
+ return;
+ }
+
+ const source = renderer.context.canvas as CanvasImageSource;
+
+ const effectDefinition = 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<GraphicNodeParams> {
- 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<GraphicNodeParams> {
+ 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<VideoNodeParams> {
- 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<VideoNodeParams> {
+ 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<string, MediaAsset>;
- 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<string, MediaAsset>;
- 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<string, MediaAsset>;
+ 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<string, MediaAsset>;
+ 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<SceneExporterEvents> {
- 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<ArrayBuffer | null> {
- 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<SceneExporterEvents> {
+ 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<ArrayBuffer | null> {
+ 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<string, number | number[]>;
- 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<string, WebGLProgram>;
- 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<string, WebGLProgram>;
- 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<string, number | number[]>;
+ 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<string, WebGLProgram>;
+ 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<string, WebGLProgram>;
+ 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<string, WebGLProgram>();
-
-export function getWebGLContext({
- width,
- height,
-}: {
- width: number;
- height: number;
-}): {
- context: WebGLRenderingContext;
- programCache: Map<string, WebGLProgram>;
-} {
- 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<string, WebGLProgram>();
+
+export function getWebGLContext({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): {
+ context: WebGLRenderingContext;
+ programCache: Map<string, WebGLProgram>;
+} {
+ 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<string, number | number[]>;
-}
-
-export const QUAD_POSITIONS = new Float32Array([
- -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1,
-]);
-
-export function compileProgram({
- context,
- fragmentShaderSource,
- programCache,
-}: {
- context: WebGLRenderingContext;
- fragmentShaderSource: string;
- programCache: Map<string, WebGLProgram>;
-}): WebGLProgram {
- const cached = programCache.get(fragmentShaderSource);
- if (cached) {
- return cached;
- }
- const vertexShader = compileShader({
- context,
- source: VERTEX_SHADER_SOURCE,
- type: context.VERTEX_SHADER,
- });
- const fragmentShader = compileShader({
- context,
- source: fragmentShaderSource,
- type: context.FRAGMENT_SHADER,
- });
- const program = context.createProgram();
- if (!program) {
- throw new Error("Failed to create WebGL program");
- }
- context.attachShader(program, vertexShader);
- context.attachShader(program, fragmentShader);
- context.linkProgram(program);
- if (!context.getProgramParameter(program, context.LINK_STATUS)) {
- const info = context.getProgramInfoLog(program);
- context.deleteProgram(program);
- throw new Error(`WebGL program link failed: ${info}`);
- }
- context.deleteShader(vertexShader);
- context.deleteShader(fragmentShader);
- programCache.set(fragmentShaderSource, program);
- return program;
-}
-
-export function compileShader({
- context,
- source,
- type,
-}: {
- context: WebGLRenderingContext;
- source: string;
- type: number;
-}): WebGLShader {
- const shader = context.createShader(type);
- if (!shader) {
- throw new Error("Failed to create WebGL shader");
- }
- context.shaderSource(shader, source);
- context.compileShader(shader);
- if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) {
- const info = context.getShaderInfoLog(shader);
- context.deleteShader(shader);
- throw new Error(`WebGL shader compile failed: ${info}`);
- }
- return shader;
-}
-
-export function createTexture({
- context,
- source,
-}: {
- context: WebGLRenderingContext;
- source: CanvasImageSource;
-}): WebGLTexture {
- const texture = context.createTexture();
- if (!texture) {
- throw new Error("Failed to create WebGL texture");
- }
- context.activeTexture(context.TEXTURE0);
- context.bindTexture(context.TEXTURE_2D, texture);
- context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, 1);
- context.texParameteri(
- context.TEXTURE_2D,
- context.TEXTURE_WRAP_S,
- context.CLAMP_TO_EDGE,
- );
- context.texParameteri(
- context.TEXTURE_2D,
- context.TEXTURE_WRAP_T,
- context.CLAMP_TO_EDGE,
- );
- context.texParameteri(
- context.TEXTURE_2D,
- context.TEXTURE_MIN_FILTER,
- context.LINEAR,
- );
- context.texParameteri(
- context.TEXTURE_2D,
- context.TEXTURE_MAG_FILTER,
- context.LINEAR,
- );
- context.texImage2D(
- context.TEXTURE_2D,
- 0,
- context.RGBA,
- context.RGBA,
- context.UNSIGNED_BYTE,
- source as TexImageSource,
- );
- return texture;
-}
-
-export function setUniforms({
- context,
- program,
- uniforms,
-}: {
- context: WebGLRenderingContext;
- program: WebGLProgram;
- uniforms: Record<string, number | number[]>;
-}): void {
- for (const [name, value] of Object.entries(uniforms)) {
- const location = context.getUniformLocation(program, name);
- if (location === null) continue;
-
- if (typeof value === "number") {
- context.uniform1f(location, value);
- } else if (Array.isArray(value)) {
- if (value.length === 2) {
- context.uniform2fv(location, new Float32Array(value));
- } else if (value.length === 3) {
- context.uniform3fv(location, new Float32Array(value));
- } else if (value.length === 4) {
- context.uniform4fv(location, new Float32Array(value));
- }
- }
- }
-}
-
-export function drawFullscreenQuad({
- context,
- program,
- width,
- height,
-}: {
- context: WebGLRenderingContext;
- program: WebGLProgram;
- width: number;
- height: number;
-}): void {
- const positionLocation = context.getAttribLocation(program, "a_position");
- const buffer = context.createBuffer();
- context.bindBuffer(context.ARRAY_BUFFER, buffer);
- context.bufferData(context.ARRAY_BUFFER, QUAD_POSITIONS, context.STATIC_DRAW);
- context.enableVertexAttribArray(positionLocation);
- context.vertexAttribPointer(positionLocation, 2, context.FLOAT, false, 0, 0);
-
- context.viewport(0, 0, width, height);
- context.clearColor(0, 0, 0, 0);
- context.clear(context.COLOR_BUFFER_BIT);
- context.drawArrays(context.TRIANGLES, 0, 6);
-}
-
-export function createFramebufferTexture({
- context,
- width,
- height,
-}: {
- context: WebGLRenderingContext;
- width: number;
- height: number;
-}): { texture: WebGLTexture; framebuffer: WebGLFramebuffer } {
- const texture = context.createTexture();
- if (!texture) throw new Error("Failed to create framebuffer texture");
- context.bindTexture(context.TEXTURE_2D, texture);
- context.texImage2D(
- context.TEXTURE_2D,
- 0,
- context.RGBA,
- width,
- height,
- 0,
- context.RGBA,
- context.UNSIGNED_BYTE,
- null,
- );
- context.texParameteri(
- context.TEXTURE_2D,
- context.TEXTURE_WRAP_S,
- context.CLAMP_TO_EDGE,
- );
- context.texParameteri(
- context.TEXTURE_2D,
- context.TEXTURE_WRAP_T,
- context.CLAMP_TO_EDGE,
- );
- context.texParameteri(
- context.TEXTURE_2D,
- context.TEXTURE_MIN_FILTER,
- context.LINEAR,
- );
- context.texParameteri(
- context.TEXTURE_2D,
- context.TEXTURE_MAG_FILTER,
- context.LINEAR,
- );
- context.bindTexture(context.TEXTURE_2D, null);
-
- const framebuffer = context.createFramebuffer();
- if (!framebuffer) throw new Error("Failed to create framebuffer");
- context.bindFramebuffer(context.FRAMEBUFFER, framebuffer);
- context.framebufferTexture2D(
- context.FRAMEBUFFER,
- context.COLOR_ATTACHMENT0,
- context.TEXTURE_2D,
- texture,
- 0,
- );
- context.bindFramebuffer(context.FRAMEBUFFER, null);
-
- return { texture, framebuffer };
-}
-
-export function applyMultiPassEffect({
- context,
- source,
- width,
- height,
- passes,
- programCache,
-}: {
- context: WebGLRenderingContext;
- source: CanvasImageSource;
- width: number;
- height: number;
- passes: EffectPassData[];
- programCache: Map<string, WebGLProgram>;
-}): void {
- const sourceTexture = createTexture({ context, source });
- let currentTexture: WebGLTexture = sourceTexture;
-
- const intermediates: Array<{
- texture: WebGLTexture;
- framebuffer: WebGLFramebuffer;
- }> = [];
- for (let i = 0; i < passes.length - 1; i++) {
- intermediates.push(createFramebufferTexture({ context, width, height }));
- }
-
- for (let i = 0; i < passes.length; i++) {
- const pass = passes[i];
- const program = compileProgram({
- context,
- fragmentShaderSource: pass.fragmentShader,
- programCache,
- });
- const isLastPass = i === passes.length - 1;
- const targetFramebuffer = isLastPass ? null : intermediates[i].framebuffer;
-
- context.bindFramebuffer(context.FRAMEBUFFER, targetFramebuffer);
- // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook
- context.useProgram(program);
- context.activeTexture(context.TEXTURE0);
- context.bindTexture(context.TEXTURE_2D, currentTexture);
-
- const uTextureLocation = context.getUniformLocation(program, "u_texture");
- if (uTextureLocation) {
- context.uniform1i(uTextureLocation, 0);
- }
-
- setUniforms({
- context,
- program,
- uniforms: { ...pass.uniforms, u_resolution: [width, height] },
- });
- drawFullscreenQuad({ context, program, width, height });
-
- if (!isLastPass) {
- currentTexture = intermediates[i].texture;
- }
- }
-
- context.deleteTexture(sourceTexture);
- for (const intermediate of intermediates) {
- context.deleteTexture(intermediate.texture);
- context.deleteFramebuffer(intermediate.framebuffer);
- }
- context.bindTexture(context.TEXTURE_2D, null);
- context.bindFramebuffer(context.FRAMEBUFFER, null);
-}
+import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl";
+
+export interface EffectPassData {
+ fragmentShader: string;
+ uniforms: Record<string, number | number[]>;
+}
+
+export const QUAD_POSITIONS = new Float32Array([
+ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1,
+]);
+
+export function compileProgram({
+ context,
+ fragmentShaderSource,
+ programCache,
+}: {
+ context: WebGLRenderingContext;
+ fragmentShaderSource: string;
+ programCache: Map<string, WebGLProgram>;
+}): WebGLProgram {
+ const cached = programCache.get(fragmentShaderSource);
+ if (cached) {
+ return cached;
+ }
+ const vertexShader = compileShader({
+ context,
+ source: VERTEX_SHADER_SOURCE,
+ type: context.VERTEX_SHADER,
+ });
+ const fragmentShader = compileShader({
+ context,
+ source: fragmentShaderSource,
+ type: context.FRAGMENT_SHADER,
+ });
+ const program = context.createProgram();
+ if (!program) {
+ throw new Error("Failed to create WebGL program");
+ }
+ context.attachShader(program, vertexShader);
+ context.attachShader(program, fragmentShader);
+ context.linkProgram(program);
+ if (!context.getProgramParameter(program, context.LINK_STATUS)) {
+ const info = context.getProgramInfoLog(program);
+ context.deleteProgram(program);
+ throw new Error(`WebGL program link failed: ${info}`);
+ }
+ context.deleteShader(vertexShader);
+ context.deleteShader(fragmentShader);
+ programCache.set(fragmentShaderSource, program);
+ return program;
+}
+
+export function compileShader({
+ context,
+ source,
+ type,
+}: {
+ context: WebGLRenderingContext;
+ source: string;
+ type: number;
+}): WebGLShader {
+ const shader = context.createShader(type);
+ if (!shader) {
+ throw new Error("Failed to create WebGL shader");
+ }
+ context.shaderSource(shader, source);
+ context.compileShader(shader);
+ if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) {
+ const info = context.getShaderInfoLog(shader);
+ context.deleteShader(shader);
+ throw new Error(`WebGL shader compile failed: ${info}`);
+ }
+ return shader;
+}
+
+export function createTexture({
+ context,
+ source,
+}: {
+ context: WebGLRenderingContext;
+ source: CanvasImageSource;
+}): WebGLTexture {
+ const texture = context.createTexture();
+ if (!texture) {
+ throw new Error("Failed to create WebGL texture");
+ }
+ context.activeTexture(context.TEXTURE0);
+ context.bindTexture(context.TEXTURE_2D, texture);
+ context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, 1);
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_S,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_T,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MIN_FILTER,
+ context.LINEAR,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MAG_FILTER,
+ context.LINEAR,
+ );
+ context.texImage2D(
+ context.TEXTURE_2D,
+ 0,
+ context.RGBA,
+ context.RGBA,
+ context.UNSIGNED_BYTE,
+ source as TexImageSource,
+ );
+ return texture;
+}
+
+export function setUniforms({
+ context,
+ program,
+ uniforms,
+}: {
+ context: WebGLRenderingContext;
+ program: WebGLProgram;
+ uniforms: Record<string, number | number[]>;
+}): void {
+ for (const [name, value] of Object.entries(uniforms)) {
+ const location = context.getUniformLocation(program, name);
+ if (location === null) continue;
+
+ if (typeof value === "number") {
+ context.uniform1f(location, value);
+ } else if (Array.isArray(value)) {
+ if (value.length === 2) {
+ context.uniform2fv(location, new Float32Array(value));
+ } else if (value.length === 3) {
+ context.uniform3fv(location, new Float32Array(value));
+ } else if (value.length === 4) {
+ context.uniform4fv(location, new Float32Array(value));
+ }
+ }
+ }
+}
+
+export function drawFullscreenQuad({
+ context,
+ program,
+ width,
+ height,
+}: {
+ context: WebGLRenderingContext;
+ program: WebGLProgram;
+ width: number;
+ height: number;
+}): void {
+ const positionLocation = context.getAttribLocation(program, "a_position");
+ const buffer = context.createBuffer();
+ context.bindBuffer(context.ARRAY_BUFFER, buffer);
+ context.bufferData(context.ARRAY_BUFFER, QUAD_POSITIONS, context.STATIC_DRAW);
+ context.enableVertexAttribArray(positionLocation);
+ context.vertexAttribPointer(positionLocation, 2, context.FLOAT, false, 0, 0);
+
+ context.viewport(0, 0, width, height);
+ context.clearColor(0, 0, 0, 0);
+ context.clear(context.COLOR_BUFFER_BIT);
+ context.drawArrays(context.TRIANGLES, 0, 6);
+}
+
+export function createFramebufferTexture({
+ context,
+ width,
+ height,
+}: {
+ context: WebGLRenderingContext;
+ width: number;
+ height: number;
+}): { texture: WebGLTexture; framebuffer: WebGLFramebuffer } {
+ const texture = context.createTexture();
+ if (!texture) throw new Error("Failed to create framebuffer texture");
+ context.bindTexture(context.TEXTURE_2D, texture);
+ context.texImage2D(
+ context.TEXTURE_2D,
+ 0,
+ context.RGBA,
+ width,
+ height,
+ 0,
+ context.RGBA,
+ context.UNSIGNED_BYTE,
+ null,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_S,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_T,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MIN_FILTER,
+ context.LINEAR,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MAG_FILTER,
+ context.LINEAR,
+ );
+ context.bindTexture(context.TEXTURE_2D, null);
+
+ const framebuffer = context.createFramebuffer();
+ if (!framebuffer) throw new Error("Failed to create framebuffer");
+ context.bindFramebuffer(context.FRAMEBUFFER, framebuffer);
+ context.framebufferTexture2D(
+ context.FRAMEBUFFER,
+ context.COLOR_ATTACHMENT0,
+ context.TEXTURE_2D,
+ texture,
+ 0,
+ );
+ context.bindFramebuffer(context.FRAMEBUFFER, null);
+
+ return { texture, framebuffer };
+}
+
+export function applyMultiPassEffect({
+ context,
+ source,
+ width,
+ height,
+ passes,
+ programCache,
+}: {
+ context: WebGLRenderingContext;
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+ passes: EffectPassData[];
+ programCache: Map<string, WebGLProgram>;
+}): void {
+ const sourceTexture = createTexture({ context, source });
+ let currentTexture: WebGLTexture = sourceTexture;
+
+ const intermediates: Array<{
+ texture: WebGLTexture;
+ framebuffer: WebGLFramebuffer;
+ }> = [];
+ for (let i = 0; i < passes.length - 1; i++) {
+ intermediates.push(createFramebufferTexture({ context, width, height }));
+ }
+
+ for (let i = 0; i < passes.length; i++) {
+ const pass = passes[i];
+ const program = compileProgram({
+ context,
+ fragmentShaderSource: pass.fragmentShader,
+ programCache,
+ });
+ const isLastPass = i === passes.length - 1;
+ const targetFramebuffer = isLastPass ? null : intermediates[i].framebuffer;
+
+ context.bindFramebuffer(context.FRAMEBUFFER, targetFramebuffer);
+ // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook
+ context.useProgram(program);
+ context.activeTexture(context.TEXTURE0);
+ context.bindTexture(context.TEXTURE_2D, currentTexture);
+
+ const uTextureLocation = context.getUniformLocation(program, "u_texture");
+ if (uTextureLocation) {
+ context.uniform1i(uTextureLocation, 0);
+ }
+
+ setUniforms({
+ context,
+ program,
+ uniforms: { ...pass.uniforms, u_resolution: [width, height] },
+ });
+ drawFullscreenQuad({ context, program, width, height });
+
+ if (!isLastPass) {
+ currentTexture = intermediates[i].texture;
+ }
+ }
+
+ context.deleteTexture(sourceTexture);
+ for (const intermediate of intermediates) {
+ context.deleteTexture(intermediate.texture);
+ context.deleteFramebuffer(intermediate.framebuffer);
+ }
+ context.bindTexture(context.TEXTURE_2D, null);
+ context.bindFramebuffer(context.FRAMEBUFFER, null);
+}
diff --git a/apps/web/src/services/storage/migrations/__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<string, unknown> }> }> }> }>)[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<string, unknown> }> }> }> }>)[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<string, unknown>).canvasSizeMode,
- ).toBe("preset");
- expect(
- (result.project.settings as Record<string, unknown>).lastCustomCanvasSize,
- ).toBeNull();
- expect(
- (result.project.settings as Record<string, unknown>).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<string, unknown>).canvasSizeMode,
+ ).toBe("preset");
+ expect(
+ (result.project.settings as Record<string, unknown>).lastCustomCanvasSize,
+ ).toBeNull();
+ expect(
+ (result.project.settings as Record<string, unknown>).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<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[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<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[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<Record<string, unknown>>
+ )[0];
+ const migratedTrack = (
+ migratedScene.tracks as Array<Record<string, unknown>>
+ )[0];
+ const migratedElement = (
+ migratedTrack.elements as Array<Record<string, unknown>>
+ )[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<Record<string, unknown>>
+ )[0];
+ const migratedTrack = (
+ migratedScene.tracks as Array<Record<string, unknown>>
+ )[0];
+ const migratedElement = (
+ migratedTrack.elements as Array<Record<string, unknown>>
+ )[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<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[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<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[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<Record<string, unknown>>
+ )[0];
+ const migratedTrack = (
+ migratedScene.tracks as Array<Record<string, unknown>>
+ )[0];
+ const migratedElement = (
+ migratedTrack.elements as Array<Record<string, unknown>>
+ )[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<Record<string, unknown>>
+ )[0];
+ const migratedTrack = (
+ migratedScene.tracks as Array<Record<string, unknown>>
+ )[0];
+ const migratedElement = (
+ migratedTrack.elements as Array<Record<string, unknown>>
+ )[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<typeof transformProjectV5ToV6>[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<typeof transformProjectV5ToV6>[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<Record<string, unknown>> })
- .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<typeof transformProjectV5ToV6>[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<typeof transformProjectV5ToV6>[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<Record<string, unknown>> })
+ .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<typeof transformProjectV8ToV9>[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<string, unknown>[])[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<typeof transformProjectV8ToV9>[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<typeof transformProjectV8ToV9>[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<typeof transformProjectV8ToV9>[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<typeof transformProjectV8ToV9>[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<string, unknown>[])[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<typeof transformProjectV8ToV9>[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<typeof transformProjectV8ToV9>[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<typeof transformProjectV8ToV9>[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<MigrationResult<ProjectRecord>>;
-}
+import type { MigrationResult, ProjectRecord } from "./transformers/types";
+
+export abstract class StorageMigration {
+ abstract from: number;
+ abstract to: number;
+ abstract transform(
+ project: ProjectRecord,
+ ): Promise<MigrationResult<ProjectRecord>>;
+}
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<StorageMigrationResult> {
- // 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<ProjectRecord>(
- "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<StorageMigrationResult> {
+ // 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<ProjectRecord>(
+ "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<ProjectRecord> {
- 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<string, unknown>).scale;
-
- let migratedElement: ProjectRecord = {
- ...element,
- transform: migratedTransform,
- };
-
- const animations = element.animations;
- if (isRecord(animations) && isRecord(animations.channels)) {
- const channels = animations.channels as Record<string, unknown>;
- 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<ProjectRecord> {
+ 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<string, unknown>).scale;
+
+ let migratedElement: ProjectRecord = {
+ ...element,
+ transform: migratedTransform,
+ };
+
+ const animations = element.animations;
+ if (isRecord(animations) && isRecord(animations.channels)) {
+ const channels = animations.channels as Record<string, unknown>;
+ 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<ProjectRecord> {
- 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<string, unknown>;
- 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<ProjectRecord> {
+ 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<string, unknown>;
+ 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<ProjectRecord> {
- 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<string, unknown>).feather;
- delete (result as Record<string, unknown>).inverted;
- delete (result as Record<string, unknown>).stroke;
-
- return result;
-}
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV12ToV13({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ 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<string, unknown>).feather;
+ delete (result as Record<string, unknown>).inverted;
+ delete (result as Record<string, unknown>).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<ProjectRecord> {
- 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<ProjectRecord> {
+ 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<ProjectRecord> {
- 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<ProjectRecord> {
+ 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<ProjectRecord> {
- 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<ProjectRecord> {
+ 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<ProjectRecord> {
- 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<ProjectRecord> {
+ 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<ProjectRecord> {
- 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<ProjectRecord> {
+ 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<ProjectRecord> {
- 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<string, unknown> = {
- ...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<ProjectRecord> {
+ 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<string, unknown> = {
+ ...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<ProjectRecord> {
- 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<ProjectRecord> {
+ 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<ProjectRecord> {
- 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<ProjectRecord> {
+ 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<ProjectRecord> {
- 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<ProjectRecord> {
+ 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<ProjectRecord> {
- const projectId = getProjectId({ project });
- if (!projectId) {
- return { project, skipped: true, reason: "no project id" };
- }
-
- if (isV7Project({ project })) {
- return { project, skipped: true, reason: "already v7" };
- }
-
- const migratedProject = migrateProjectTextElements({ project });
-
- return {
- project: { ...migratedProject, version: 7 },
- skipped: false,
- };
-}
-
-function migrateProjectTextElements({
- project,
-}: {
- project: ProjectRecord;
-}): ProjectRecord {
- const scenesValue = project.scenes;
- if (!Array.isArray(scenesValue)) return project;
-
- let hasChanges = false;
- const migratedScenes = scenesValue.map((scene) => {
- const migrated = migrateSceneTextElements({ scene });
- if (migrated !== scene) hasChanges = true;
- return migrated;
- });
-
- if (!hasChanges) return project;
- return { ...project, scenes: migratedScenes };
-}
-
-function migrateSceneTextElements({ scene }: { scene: unknown }): unknown {
- if (!isRecord(scene)) return scene;
-
- const tracksValue = scene.tracks;
- if (!Array.isArray(tracksValue)) return scene;
-
- let hasChanges = false;
- const migratedTracks = tracksValue.map((track) => {
- const migrated = migrateTrackTextElements({ track });
- if (migrated !== track) hasChanges = true;
- return migrated;
- });
-
- if (!hasChanges) return scene;
- return { ...scene, tracks: migratedTracks };
-}
-
-function migrateTrackTextElements({ track }: { track: unknown }): unknown {
- if (!isRecord(track)) return track;
-
- const elementsValue = track.elements;
- if (!Array.isArray(elementsValue)) return track;
-
- let hasChanges = false;
- const migratedElements = elementsValue.map((element) => {
- const migrated = migrateTextElement({ element });
- if (migrated !== element) hasChanges = true;
- return migrated;
- });
-
- if (!hasChanges) return track;
- return { ...track, elements: migratedElements };
-}
-
-function migrateTextElement({ element }: { element: unknown }): unknown {
- if (!isRecord(element)) return element;
- if (element.type !== "text") return element;
- if (isRecord(element.background)) return element;
-
- const backgroundColor =
- typeof element.backgroundColor === "string"
- ? element.backgroundColor
- : "transparent";
-
- const { backgroundColor: _removed, ...rest } = element;
-
- return {
- ...rest,
- background: {
- color: backgroundColor,
- cornerRadius: 0,
- paddingX: 8,
- paddingY: 4,
- offsetX: 0,
- offsetY: 0,
- },
- };
-}
-
-function isV7Project({ project }: { project: ProjectRecord }): boolean {
- return typeof project.version === "number" && project.version >= 7;
-}
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV6ToV7({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ const projectId = getProjectId({ project });
+ if (!projectId) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (isV7Project({ project })) {
+ return { project, skipped: true, reason: "already v7" };
+ }
+
+ const migratedProject = migrateProjectTextElements({ project });
+
+ return {
+ project: { ...migratedProject, version: 7 },
+ skipped: false,
+ };
+}
+
+function migrateProjectTextElements({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) return project;
+
+ let hasChanges = false;
+ const migratedScenes = scenesValue.map((scene) => {
+ const migrated = migrateSceneTextElements({ scene });
+ if (migrated !== scene) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return project;
+ return { ...project, scenes: migratedScenes };
+}
+
+function migrateSceneTextElements({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) return scene;
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) return scene;
+
+ let hasChanges = false;
+ const migratedTracks = tracksValue.map((track) => {
+ const migrated = migrateTrackTextElements({ track });
+ if (migrated !== track) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return scene;
+ return { ...scene, tracks: migratedTracks };
+}
+
+function migrateTrackTextElements({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) return track;
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) return track;
+
+ let hasChanges = false;
+ const migratedElements = elementsValue.map((element) => {
+ const migrated = migrateTextElement({ element });
+ if (migrated !== element) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return track;
+ return { ...track, elements: migratedElements };
+}
+
+function migrateTextElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) return element;
+ if (element.type !== "text") return element;
+ if (isRecord(element.background)) return element;
+
+ const backgroundColor =
+ typeof element.backgroundColor === "string"
+ ? element.backgroundColor
+ : "transparent";
+
+ const { backgroundColor: _removed, ...rest } = element;
+
+ return {
+ ...rest,
+ background: {
+ color: backgroundColor,
+ cornerRadius: 0,
+ paddingX: 8,
+ paddingY: 4,
+ offsetX: 0,
+ offsetY: 0,
+ },
+ };
+}
+
+function isV7Project({ project }: { project: ProjectRecord }): boolean {
+ return typeof project.version === "number" && project.version >= 7;
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts
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<ProjectRecord> {
- const projectId = getProjectId({ project });
- if (!projectId) {
- return { project, skipped: true, reason: "no project id" };
- }
-
- if (isV8Project({ project })) {
- return { project, skipped: true, reason: "already v8" };
- }
-
- const migratedProject = migrateProjectElements({ project });
-
- return {
- project: { ...migratedProject, version: 8 },
- skipped: false,
- };
-}
-
-function migrateProjectElements({
- project,
-}: {
- project: ProjectRecord;
-}): ProjectRecord {
- const scenesValue = project.scenes;
- if (!Array.isArray(scenesValue)) return project;
-
- let hasChanges = false;
- const migratedScenes = scenesValue.map((scene) => {
- const migrated = migrateSceneElements({ scene });
- if (migrated !== scene) hasChanges = true;
- return migrated;
- });
-
- if (!hasChanges) return project;
- return { ...project, scenes: migratedScenes };
-}
-
-function migrateSceneElements({ scene }: { scene: unknown }): unknown {
- if (!isRecord(scene)) return scene;
-
- const tracksValue = scene.tracks;
- if (!Array.isArray(tracksValue)) return scene;
-
- let hasChanges = false;
- const migratedTracks = tracksValue.map((track) => {
- const migrated = migrateTrackElements({ track });
- if (migrated !== track) hasChanges = true;
- return migrated;
- });
-
- if (!hasChanges) return scene;
- return { ...scene, tracks: migratedTracks };
-}
-
-function migrateTrackElements({ track }: { track: unknown }): unknown {
- if (!isRecord(track)) return track;
-
- const elementsValue = track.elements;
- if (!Array.isArray(elementsValue)) return track;
-
- let hasChanges = false;
- const migratedElements = elementsValue.map((element) => {
- const migrated = migrateElement({ element });
- if (migrated !== element) hasChanges = true;
- return migrated;
- });
-
- if (!hasChanges) return track;
- return { ...track, elements: migratedElements };
-}
-
-function migrateElement({ element }: { element: unknown }): unknown {
- if (!isRecord(element)) return element;
- if (element.type !== "video" && element.type !== "audio") return element;
- if (typeof element.sourceDuration === "number") return element;
-
- const trimStart = typeof element.trimStart === "number" ? element.trimStart : 0;
- const duration = typeof element.duration === "number" ? element.duration : 0;
- const trimEnd = typeof element.trimEnd === "number" ? element.trimEnd : 0;
-
- return {
- ...element,
- sourceDuration: trimStart + duration + trimEnd,
- };
-}
-
-function isV8Project({ project }: { project: ProjectRecord }): boolean {
- return typeof project.version === "number" && project.version >= 8;
-}
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV7ToV8({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ const projectId = getProjectId({ project });
+ if (!projectId) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (isV8Project({ project })) {
+ return { project, skipped: true, reason: "already v8" };
+ }
+
+ const migratedProject = migrateProjectElements({ project });
+
+ return {
+ project: { ...migratedProject, version: 8 },
+ skipped: false,
+ };
+}
+
+function migrateProjectElements({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) return project;
+
+ let hasChanges = false;
+ const migratedScenes = scenesValue.map((scene) => {
+ const migrated = migrateSceneElements({ scene });
+ if (migrated !== scene) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return project;
+ return { ...project, scenes: migratedScenes };
+}
+
+function migrateSceneElements({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) return scene;
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) return scene;
+
+ let hasChanges = false;
+ const migratedTracks = tracksValue.map((track) => {
+ const migrated = migrateTrackElements({ track });
+ if (migrated !== track) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return scene;
+ return { ...scene, tracks: migratedTracks };
+}
+
+function migrateTrackElements({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) return track;
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) return track;
+
+ let hasChanges = false;
+ const migratedElements = elementsValue.map((element) => {
+ const migrated = migrateElement({ element });
+ if (migrated !== element) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return track;
+ return { ...track, elements: migratedElements };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) return element;
+ if (element.type !== "video" && element.type !== "audio") return element;
+ if (typeof element.sourceDuration === "number") return element;
+
+ const trimStart = typeof element.trimStart === "number" ? element.trimStart : 0;
+ const duration = typeof element.duration === "number" ? element.duration : 0;
+ const trimEnd = typeof element.trimEnd === "number" ? element.trimEnd : 0;
+
+ return {
+ ...element,
+ sourceDuration: trimStart + duration + trimEnd,
+ };
+}
+
+function isV8Project({ project }: { project: ProjectRecord }): boolean {
+ return typeof project.version === "number" && project.version >= 8;
+}
diff --git a/apps/web/src/services/storage/migrations/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<ProjectRecord> {
- 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<ProjectRecord> {
+ 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<ProjectRecord> {
- 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<ProjectRecord> {
+ 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<StorageQuotaStatus> {
- 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<StorageQuotaStatus> {
+ 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<TranscriptionResult> {
- await this.ensureWorker({ modelId, onProgress });
-
- return new Promise((resolve, reject) => {
- if (!this.worker) {
- reject(new Error("Worker not initialized"));
- return;
- }
-
- const handleMessage = (event: MessageEvent<WorkerResponse>) => {
- 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<void> {
- 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<WorkerResponse>) => {
- 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<void> {
- 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<TranscriptionResult> {
+ await this.ensureWorker({ modelId, onProgress });
+
+ return new Promise((resolve, reject) => {
+ if (!this.worker) {
+ reject(new Error("Worker not initialized"));
+ return;
+ }
+
+ const handleMessage = (event: MessageEvent<WorkerResponse>) => {
+ 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<void> {
+ 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<WorkerResponse>) => {
+ 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<void> {
+ 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<string, { loaded: number; total: number }>();
-
-self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
- 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<string, { loaded: number; total: number }>();
+
+self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
+ 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);
+ }
+}