From a9b9cf6bf50f08723896659634f2e6afc17bcd42 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 29 Apr 2026 00:37:56 +0200 Subject: chore: switch from biome to eslint + prettier; fix ton of lint issues --- apps/web/src/services/renderer/canvas-renderer.ts | 41 ++------ apps/web/src/services/renderer/canvas-utils.ts | 19 ++-- .../renderer/compositor/frame-descriptor.ts | 23 ++--- apps/web/src/services/renderer/effect-preview.ts | 25 ++--- apps/web/src/services/renderer/gpu-renderer.ts | 53 ++--------- apps/web/src/services/renderer/mask-feather.ts | 6 +- .../src/services/renderer/nodes/graphic-node.ts | 17 +--- apps/web/src/services/renderer/nodes/image-node.ts | 11 ++- apps/web/src/services/renderer/resolve.ts | 10 +- apps/web/src/services/storage/indexeddb-adapter.ts | 18 +++- .../storage/migrations/__tests__/helpers.ts | 39 ++++++++ .../storage/migrations/__tests__/v0-to-v1.test.ts | 7 +- .../storage/migrations/__tests__/v1-to-v2.test.ts | 41 ++++---- .../migrations/__tests__/v15-to-v16.test.ts | 9 +- .../migrations/__tests__/v16-to-v17.test.ts | 13 +-- .../migrations/__tests__/v18-to-v19.test.ts | 17 ++-- .../migrations/__tests__/v19-to-v20.test.ts | 21 ++--- .../storage/migrations/__tests__/v2-to-v3.test.ts | 17 ++-- .../migrations/__tests__/v20-to-v21.test.ts | 11 ++- .../migrations/__tests__/v21-to-v22.test.ts | 13 +-- .../migrations/__tests__/v22-to-v23.test.ts | 20 ++-- .../migrations/__tests__/v26-to-v27.test.ts | 15 +-- .../migrations/__tests__/v27-to-v28.test.ts | 37 ++++---- .../storage/migrations/__tests__/v3-to-v4.test.ts | 25 ++--- .../storage/migrations/__tests__/v4-to-v5.test.ts | 25 ++--- .../storage/migrations/__tests__/v5-to-v6.test.ts | 17 +--- .../storage/migrations/__tests__/v8-to-v9.test.ts | 26 +++--- .../storage/migrations/transformers/v1-to-v2.ts | 104 ++++++++++----------- .../storage/migrations/transformers/v13-to-v14.ts | 5 +- .../storage/migrations/transformers/v26-to-v27.ts | 17 ++-- .../storage/migrations/transformers/v3-to-v4.ts | 15 ++- apps/web/src/services/storage/opfs-adapter.ts | 8 +- apps/web/src/services/storage/service.ts | 55 +++++++---- apps/web/src/services/storage/types.ts | 2 +- 34 files changed, 363 insertions(+), 419 deletions(-) create mode 100644 apps/web/src/services/storage/migrations/__tests__/helpers.ts (limited to 'apps/web/src/services') diff --git a/apps/web/src/services/renderer/canvas-renderer.ts b/apps/web/src/services/renderer/canvas-renderer.ts index 4a002675..58294e9c 100644 --- a/apps/web/src/services/renderer/canvas-renderer.ts +++ b/apps/web/src/services/renderer/canvas-renderer.ts @@ -1,5 +1,6 @@ import type { FrameRate } from "opencut-wasm"; import type { AnyBaseNode } from "./nodes/base-node"; +import { createCanvasSurface } from "./canvas-utils"; import { buildFrameDescriptor } from "./compositor/frame-descriptor"; import { wasmCompositor } from "./compositor/wasm-compositor"; import { resolveRenderTree } from "./resolve"; @@ -16,8 +17,8 @@ export type CanvasRendererParams = { }; export class CanvasRenderer { - canvas: OffscreenCanvas | HTMLCanvasElement; - context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; + canvas: OffscreenCanvas; + context: OffscreenCanvasRenderingContext2D; width: number; height: number; fps: FrameRate; @@ -27,22 +28,9 @@ export class CanvasRenderer { this.height = height; this.fps = fps; - try { - this.canvas = new OffscreenCanvas(width, height); - } catch { - this.canvas = document.createElement("canvas"); - this.canvas.width = width; - this.canvas.height = height; - } - - const context = this.canvas.getContext("2d"); - if (!context) { - throw new Error("Failed to get canvas context"); - } - - this.context = context as - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D; + const surface = createCanvasSurface({ width, height }); + this.canvas = surface.canvas; + this.context = surface.context; } getOutputCanvas(): HTMLCanvasElement { @@ -57,20 +45,9 @@ export class CanvasRenderer { this.width = width; this.height = height; - if (this.canvas instanceof OffscreenCanvas) { - this.canvas = new OffscreenCanvas(width, height); - } else { - this.canvas.width = width; - this.canvas.height = height; - } - - const context = this.canvas.getContext("2d"); - if (!context) { - throw new Error("Failed to get canvas context"); - } - this.context = context as - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D; + const surface = createCanvasSurface({ width, height }); + this.canvas = surface.canvas; + this.context = surface.context; } async render({ node, time }: { node: AnyBaseNode; time: number }) { diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts index 093168e6..6981e6d5 100644 --- a/apps/web/src/services/renderer/canvas-utils.ts +++ b/apps/web/src/services/renderer/canvas-utils.ts @@ -1,16 +1,17 @@ -export function createOffscreenCanvas({ +export function createCanvasSurface({ 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; +}): { + canvas: OffscreenCanvas; + context: OffscreenCanvasRenderingContext2D; +} { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Failed to create 2D rendering context"); } + return { canvas, context }; } diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts index 190a0237..1bc316ef 100644 --- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts +++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts @@ -3,7 +3,7 @@ import { masksRegistry } from "@/masks"; import { incrementCounter } from "@/diagnostics/render-perf"; import type { AnyBaseNode } from "../nodes/base-node"; import type { CanvasRenderer } from "../canvas-renderer"; -import { createOffscreenCanvas } from "../canvas-utils"; +import { createCanvasSurface } from "../canvas-utils"; import { BlurBackgroundNode } from "../nodes/blur-background-node"; import { ColorNode } from "../nodes/color-node"; import { EffectLayerNode } from "../nodes/effect-layer-node"; @@ -414,15 +414,11 @@ function buildMaskArtifacts({ const { width: canvasWidth, height: canvasHeight } = renderer; const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`; const drawMask: TextureCanvasDrawFn = (ctx) => { - const elementMaskCanvas = createOffscreenCanvas({ - width: Math.round(transform.width), - height: Math.round(transform.height), - }); - const elementMaskCtx = elementMaskCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!elementMaskCtx) return; + const { canvas: elementMaskCanvas, context: elementMaskCtx } = + createCanvasSurface({ + width: Math.round(transform.width), + height: Math.round(transform.height), + }); if (shouldRenderMaskDirectly && definition.renderer.renderMask) { definition.renderer.renderMask({ @@ -465,15 +461,10 @@ function buildMaskArtifacts({ const strokeTextureId = `${path}:mask-stroke`; const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`; const drawStroke: TextureCanvasDrawFn = (ctx) => { - const strokeCanvas = createOffscreenCanvas({ + const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({ width: Math.round(transform.width), height: Math.round(transform.height), }); - const strokeCtx = strokeCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!strokeCtx) return; if (definition.renderer.renderStroke) { definition.renderer.renderStroke({ diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts index c4cacde0..cbfb1a31 100644 --- a/apps/web/src/services/renderer/effect-preview.ts +++ b/apps/web/src/services/renderer/effect-preview.ts @@ -1,4 +1,4 @@ -import { createOffscreenCanvas } from "./canvas-utils"; +import { createCanvasSurface } from "./canvas-utils"; import { effectsRegistry, resolveEffectPasses } from "@/effects"; import { buildDefaultParamValues } from "@/params/registry"; import type { ParamValues } from "@/params"; @@ -8,7 +8,7 @@ const PREVIEW_SIZE = 160; const PREVIEW_IMAGE_PATH = "/effects/preview.jpg"; class EffectPreviewService { - private testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; + private testSourceCanvas: OffscreenCanvas | null = null; private previewImageElement: HTMLImageElement | null = null; private onReadyCallbacks = new Set<() => void>(); @@ -98,7 +98,7 @@ class EffectPreviewService { }: { width: number; height: number; - }): OffscreenCanvas | HTMLCanvasElement | null { + }): OffscreenCanvas | null { const isImageReady = this.previewImageElement?.complete && (this.previewImageElement.naturalWidth ?? 0) > 0; @@ -106,15 +106,8 @@ class EffectPreviewService { return null; } - const canvas = createOffscreenCanvas({ width, height }); - const ctx = canvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!ctx) { - throw new Error("failed to get 2d context for test source"); - } - ctx.drawImage(this.previewImageElement, 0, 0, width, height); + const { canvas, context } = createCanvasSurface({ width, height }); + context.drawImage(this.previewImageElement, 0, 0, width, height); return canvas; } @@ -124,7 +117,7 @@ class EffectPreviewService { }: { width: number; height: number; - }): CanvasImageSource | null { + }): OffscreenCanvas | null { if ( !this.testSourceCanvas || this.testSourceCanvas.width !== width || @@ -141,17 +134,17 @@ class EffectPreviewService { height, passes, }: { - source: CanvasImageSource; + source: OffscreenCanvas; width: number; height: number; passes: ReturnType; - }): OffscreenCanvas | HTMLCanvasElement { + }): OffscreenCanvas { return gpuRenderer.applyEffect({ source, width, height, passes, - }) as OffscreenCanvas | HTMLCanvasElement; + }); } } diff --git a/apps/web/src/services/renderer/gpu-renderer.ts b/apps/web/src/services/renderer/gpu-renderer.ts index c61b6b98..c1be3f1a 100644 --- a/apps/web/src/services/renderer/gpu-renderer.ts +++ b/apps/web/src/services/renderer/gpu-renderer.ts @@ -34,23 +34,17 @@ export const gpuRenderer = { height, passes, }: { - source: CanvasImageSource; + source: OffscreenCanvas; width: number; height: number; passes: EffectPass[]; - }): CanvasImageSource { + }): OffscreenCanvas { if (passes.length === 0 || !gpuAvailable) { return source; } - const sourceCanvas = ensureOffscreenCanvas({ - source, - width, - height, - label: "effect source", - }); return applyEffectPasses({ - source: sourceCanvas, + source, width, height, passes: serializeEffectPasses(passes), @@ -63,23 +57,17 @@ export const gpuRenderer = { height, feather, }: { - maskCanvas: CanvasImageSource; + maskCanvas: OffscreenCanvas; width: number; height: number; feather: number; - }): CanvasImageSource { + }): OffscreenCanvas { if (!gpuAvailable) { return maskCanvas; } - const sourceCanvas = ensureOffscreenCanvas({ - source: maskCanvas, - width, - height, - label: "mask source", - }); return applyMaskFeatherWasm({ - mask: sourceCanvas, + mask: maskCanvas, width, height, feather, @@ -87,35 +75,6 @@ export const gpuRenderer = { }, }; -function ensureOffscreenCanvas({ - source, - width, - height, - label, -}: { - source: CanvasImageSource; - width: number; - height: number; - label: string; -}): OffscreenCanvas { - if (source instanceof OffscreenCanvas) { - return source; - } - - if (typeof OffscreenCanvas === "undefined") { - throw new Error(`OffscreenCanvas is required for the GPU ${label}`); - } - - const canvas = new OffscreenCanvas(width, height); - const context = canvas.getContext("2d"); - if (!context) { - throw new Error(`Failed to get 2d context for the GPU ${label}`); - } - context.clearRect(0, 0, width, height); - context.drawImage(source, 0, 0, width, height); - return canvas; -} - function serializeEffectPasses(passes: EffectPass[]) { return passes.map((pass) => ({ shader: pass.shader, diff --git a/apps/web/src/services/renderer/mask-feather.ts b/apps/web/src/services/renderer/mask-feather.ts index 4ea7eb2b..dde4decc 100644 --- a/apps/web/src/services/renderer/mask-feather.ts +++ b/apps/web/src/services/renderer/mask-feather.ts @@ -6,15 +6,15 @@ export function applyMaskFeather({ height, feather, }: { - maskCanvas: CanvasImageSource; + maskCanvas: OffscreenCanvas; width: number; height: number; feather: number; -}): OffscreenCanvas | HTMLCanvasElement { +}): OffscreenCanvas { return gpuRenderer.applyMaskFeather({ maskCanvas, width, height, feather, - }) as OffscreenCanvas | HTMLCanvasElement; + }); } diff --git a/apps/web/src/services/renderer/nodes/graphic-node.ts b/apps/web/src/services/renderer/nodes/graphic-node.ts index 2d022510..798b56a0 100644 --- a/apps/web/src/services/renderer/nodes/graphic-node.ts +++ b/apps/web/src/services/renderer/nodes/graphic-node.ts @@ -1,4 +1,4 @@ -import { createOffscreenCanvas } from "../canvas-utils"; +import { createCanvasSurface } from "../canvas-utils"; import { DEFAULT_GRAPHIC_SOURCE_SIZE, getGraphicDefinition, @@ -25,7 +25,7 @@ export class GraphicNode extends VisualNode< ResolvedGraphicNodeState > { private cachedKey: string | null = null; - private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null; + private cachedSource: OffscreenCanvas | null = null; constructor(params: GraphicNodeParams) { super(params); @@ -36,7 +36,7 @@ export class GraphicNode extends VisualNode< resolvedParams, }: { resolvedParams: ParamValues; - }): OffscreenCanvas | HTMLCanvasElement | null { + }): OffscreenCanvas { const definition = getGraphicDefinition({ definitionId: this.params.definitionId, }); @@ -48,20 +48,13 @@ export class GraphicNode extends VisualNode< return this.cachedSource; } - const canvas = createOffscreenCanvas({ + const { canvas, context } = createCanvasSurface({ 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, + ctx: context, params: resolvedParams, width: DEFAULT_GRAPHIC_SOURCE_SIZE, height: DEFAULT_GRAPHIC_SOURCE_SIZE, diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index d8c56803..4f475a2d 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -17,10 +17,13 @@ export interface CachedImageSource { const imageSourceCache = new Map>(); -export function loadImageSource( - url: string, - maxSourceSize?: number, -): Promise { +export function loadImageSource({ + url, + maxSourceSize, +}: { + url: string; + maxSourceSize?: number; +}): Promise { const cacheKey = `${url}::${maxSourceSize ?? "full"}`; const cached = imageSourceCache.get(cacheKey); diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts index 36a6ecb5..4637df8d 100644 --- a/apps/web/src/services/renderer/resolve.ts +++ b/apps/web/src/services/renderer/resolve.ts @@ -235,10 +235,10 @@ async function resolveImageNode({ node: ImageNode; context: ResolveContext; }): Promise { - const source = await loadImageSource( - node.params.url, - node.params.maxSourceSize, - ); + const source = await loadImageSource({ + url: node.params.url, + maxSourceSize: node.params.maxSourceSize, + }); const visualState = resolveVisualState({ params: node.params, context, @@ -434,7 +434,7 @@ async function resolveBackdropSource({ }; } - const source = await loadImageSource(node.params.url); + const source = await loadImageSource({ url: node.params.url }); return { source: source.source, width: source.width, diff --git a/apps/web/src/services/storage/indexeddb-adapter.ts b/apps/web/src/services/storage/indexeddb-adapter.ts index 2df4feaa..4a7498b6 100644 --- a/apps/web/src/services/storage/indexeddb-adapter.ts +++ b/apps/web/src/services/storage/indexeddb-adapter.ts @@ -5,7 +5,15 @@ export class IndexedDBAdapter implements StorageAdapter { private storeName: string; private version: number; - constructor(dbName: string, storeName: string, version = 1) { + constructor({ + dbName, + storeName, + version = 1, + }: { + dbName: string; + storeName: string; + version?: number; + }) { this.dbName = dbName; this.storeName = storeName; this.version = version; @@ -39,7 +47,13 @@ export class IndexedDBAdapter implements StorageAdapter { }); } - async set(key: string, value: T): Promise { + async set({ + key, + value, + }: { + key: string; + value: T; + }): Promise { const db = await this.getDB(); const transaction = db.transaction([this.storeName], "readwrite"); const store = transaction.objectStore(this.storeName); diff --git a/apps/web/src/services/storage/migrations/__tests__/helpers.ts b/apps/web/src/services/storage/migrations/__tests__/helpers.ts new file mode 100644 index 00000000..381cb935 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/helpers.ts @@ -0,0 +1,39 @@ +/** + * Type-safe accessors for navigating migration output. + * + * Migrations input/output `Record` because they handle + * potentially malformed data from older versions. Tests need to inspect + * specific properties of the migrated result without using type assertions. + * + * These helpers narrow through type guards (Array.isArray + a record + * predicate), so the file contains no unsafe assertions despite turning + * unknown into concrete shapes. + */ + +function describe(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function asRecord(value: unknown): Record { + if (!isRecord(value)) { + throw new Error(`Expected record, got ${describe(value)}`); + } + return value; +} + +export function asArray(value: unknown): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`Expected array, got ${describe(value)}`); + } + return value; +} + +export function asRecordArray(value: unknown): Record[] { + return asArray(value).map(asRecord); +} diff --git a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts index 2cd67da8..73b64fed 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts @@ -8,6 +8,7 @@ import { v0ProjectWithMetadata, v1Project, } from "./fixtures"; +import { asArray, asRecord, asRecordArray } from "./helpers"; describe("V0 to V1 Migration", () => { const fixedDate = new Date("2024-06-01T12:00:00.000Z"); @@ -22,7 +23,7 @@ describe("V0 to V1 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(1); expect(Array.isArray(result.project.scenes)).toBe(true); - expect((result.project.scenes as unknown[]).length).toBe(1); + expect(asArray(result.project.scenes).length).toBe(1); expect(result.project.currentSceneId).toBeDefined(); }); @@ -32,7 +33,7 @@ describe("V0 to V1 Migration", () => { options: { now: fixedDate }, }); - const scenes = result.project.scenes as Array>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; expect(mainScene.isMain).toBe(true); @@ -48,7 +49,7 @@ describe("V0 to V1 Migration", () => { options: { now: fixedDate }, }); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); expect(metadata.updatedAt).toBe(fixedDate.toISOString()); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts index 0840ac76..6670c479 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts @@ -11,6 +11,7 @@ import { v1ProjectWithMultipleScenes, v2Project, } from "./fixtures"; +import { asRecord, asRecordArray } from "./helpers"; const DEFAULT_FPS = 30; const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10; @@ -25,7 +26,7 @@ describe("V1 to V2 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(2); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); expect(metadata.id).toBe(v1Project.id); expect(metadata.name).toBe(v1Project.name); expect(typeof metadata.createdAt).toBe("string"); @@ -35,7 +36,7 @@ describe("V1 to V2 Migration", () => { test("creates settings object from flat properties", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const settings = result.project.settings as Record; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(v1Project.fps); expect(settings.canvasSize).toEqual(v1Project.canvasSize); expect(settings.originalCanvasSize).toBe(null); @@ -44,8 +45,8 @@ describe("V1 to V2 Migration", () => { test("converts color background correctly", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const settings = result.project.settings as Record; - const background = settings.background as Record; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.type).toBe("color"); expect(background.color).toBe(v1Project.backgroundColor); }); @@ -58,8 +59,8 @@ describe("V1 to V2 Migration", () => { }; const result = transformProjectV1ToV2({ project: projectWithBlur }); - const settings = result.project.settings as Record; - const background = settings.background as Record; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.type).toBe("blur"); expect(background.blurIntensity).toBe(30); }); @@ -67,7 +68,7 @@ describe("V1 to V2 Migration", () => { test("applies legacy bookmarks to main scene", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const scenes = result.project.scenes as Array>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes.find((s) => s.isMain === true); expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks); }); @@ -77,7 +78,7 @@ describe("V1 to V2 Migration", () => { project: v1ProjectWithMultipleScenes, }); - const scenes = result.project.scenes as Array>; + const scenes = asRecordArray(result.project.scenes); const introScene = scenes.find((s) => s.name === "Intro"); expect(introScene?.bookmarks).toEqual([1.0]); }); @@ -102,7 +103,7 @@ describe("V1 to V2 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(DEFAULT_FPS); expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); }); @@ -115,11 +116,11 @@ describe("V1 to V2 Migration", () => { }; const result = transformProjectV1ToV2({ project: minimalProject }); - const settings = result.project.settings as Record; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(DEFAULT_FPS); expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); - const background = settings.background as Record; + const background = asRecord(settings.background); expect(background.type).toBe("color"); expect(background.color).toBe(DEFAULT_BACKGROUND_COLOR); }); @@ -135,8 +136,8 @@ describe("V1 to V2 Migration", () => { project: projectWithBlurNoIntensity, }); - const settings = result.project.settings as Record; - const background = settings.background as Record; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe(DEFAULT_BACKGROUND_BLUR_INTENSITY); }); @@ -183,9 +184,9 @@ describe("V1 to V2 Migration", () => { project: projectWithTracks, }); - const scenes = result.project.scenes as Array>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; - const tracks = mainScene.tracks as Array>; + const tracks = asRecordArray(mainScene.tracks); expect(tracks.length).toBe(1); expect(tracks[0].name).toBe("Existing Track"); }); @@ -240,9 +241,9 @@ describe("V1 to V2 Migration", () => { context, }); - const scenes = result.project.scenes as Array>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; - const tracks = mainScene.tracks as Array>; + const tracks = asRecordArray(mainScene.tracks); expect(Array.isArray(tracks)).toBe(true); expect(tracks).toHaveLength(1); expect(tracks[0].type).toBe("video"); @@ -302,9 +303,9 @@ describe("V1 to V2 Migration", () => { }); expect(result.skipped).toBe(false); - const scenes = result.project.scenes as Array>; - const tracks = scenes[0].tracks as Array>; - const elements = tracks[0].elements as Array>; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecordArray(scenes[0].tracks); + const elements = asRecordArray(tracks[0].elements); const textElement = elements[0]; expect(textElement.opacity).toBe(0.5); expect(textElement.transform).toEqual({ 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 122af2cb..1af445ac 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,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV15ToV16 } from "../transformers/v15-to-v16"; +import { asRecordArray } from "./helpers"; describe("V15 to V16 Migration", () => { test("renames sticker tracks to graphic tracks", () => { @@ -61,10 +62,10 @@ describe("V15 to V16 Migration", () => { 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"); + const firstTrack = asRecordArray( + asRecordArray(result.project.scenes)[0].tracks, + )[0]; + expect(firstTrack.type).toBe("graphic"); }); test("skips projects already on v16", () => { 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 352ca762..10a792ca 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,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV16ToV17 } from "../transformers/v16-to-v17"; +import { asRecord, asRecordArray } from "./helpers"; describe("V16 to V17 Migration", () => { test("adds center stroke alignment to masks that do not have it", () => { @@ -97,13 +98,13 @@ describe("V16 to V17 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(17); - const migratedMasks = ( - ((result.project.scenes as Array<{ tracks: Array<{ elements: Array<{ masks: Array<{ params: Record }> }> }> }>)[0] - .tracks[0].elements[0].masks) - ); + const firstScene = asRecordArray(result.project.scenes)[0]; + const firstTrack = asRecordArray(firstScene.tracks)[0]; + const firstElement = asRecordArray(firstTrack.elements)[0]; + const migratedMasks = asRecordArray(firstElement.masks); - expect(migratedMasks[0].params.strokeAlign).toBe("center"); - expect(migratedMasks[1].params.strokeAlign).toBe("outside"); + expect(asRecord(migratedMasks[0].params).strokeAlign).toBe("center"); + expect(asRecord(migratedMasks[1].params).strokeAlign).toBe("outside"); }); test("skips projects already on v17", () => { 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 0f679b6d..0cb30f2b 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,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV18ToV19 } from "../transformers/v18-to-v19"; +import { asRecord } from "./helpers"; describe("V18 to V19 Migration", () => { test("adds canvas size mode and empty remembered custom size defaults", () => { @@ -26,15 +27,13 @@ describe("V18 to V19 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(19); - expect( - (result.project.settings as Record).canvasSizeMode, - ).toBe("preset"); - expect( - (result.project.settings as Record).lastCustomCanvasSize, - ).toBeNull(); - expect( - (result.project.settings as Record).originalCanvasSize, - ).toEqual({ width: 1920, height: 1080 }); + const settings = asRecord(result.project.settings); + expect(settings.canvasSizeMode).toBe("preset"); + expect(settings.lastCustomCanvasSize).toBeNull(); + expect(settings.originalCanvasSize).toEqual({ + width: 1920, + height: 1080, + }); }); test("skips projects already on v19", () => { diff --git a/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts b/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts index 0e0cfbbe..b1bfe86f 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV19ToV20 } from "../transformers/v19-to-v20"; +import { asRecordArray } from "./helpers"; describe("V19 to V20 Migration", () => { test("backfills source audio enabled state on video elements", () => { @@ -82,19 +83,16 @@ describe("V19 to V20 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(20); - expect( - ((result.project.scenes as Array>)[0] - .tracks as Array>)[0].elements, - ).toEqual([ + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecordArray(scene.tracks); + expect(tracks[0].elements).toEqual([ expect.objectContaining({ id: "video-1", isSourceAudioEnabled: true, }), ]); expect( - (((result.project.scenes as Array>)[0] - .tracks as Array>)[1] - .elements as Array>)[0].isSourceAudioEnabled, + asRecordArray(tracks[1].elements)[0].isSourceAudioEnabled, ).toBeUndefined(); }); @@ -120,11 +118,10 @@ describe("V19 to V20 Migration", () => { }, }); - expect( - (((result.project.scenes as Array>)[0] - .tracks as Array>)[0] - .elements as Array>)[0].isSourceAudioEnabled, - ).toBe(false); + const scene = asRecordArray(result.project.scenes)[0]; + const track = asRecordArray(scene.tracks)[0]; + const element = asRecordArray(track.elements)[0]; + expect(element.isSourceAudioEnabled).toBe(false); }); test("skips projects already on v20", () => { diff --git a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts index ba8b3bfb..d18b4dd4 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts @@ -8,6 +8,7 @@ import { v2ProjectWithBlurBackground, v3Project, } from "./fixtures"; +import { asRecord } from "./helpers"; describe("V2 to V3 Migration", () => { describe("transformProjectV2ToV3", () => { @@ -17,14 +18,14 @@ describe("V2 to V3 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(3); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); expect(typeof metadata.duration).toBe("number"); }); test("calculates duration from scene tracks", () => { const result = transformProjectV2ToV3({ project: v2Project }); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); // v2Project has a video element with duration 15.5 and a text element at startTime 2 with duration 5 // Total duration should be max(15.5, 2+5) = 15.5 expect(metadata.duration).toBe(15.5); @@ -36,7 +37,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); // v2ProjectWithBlurBackground has a video with duration 30 expect(metadata.duration).toBe(30); }); @@ -45,7 +46,7 @@ describe("V2 to V3 Migration", () => { const result = transformProjectV2ToV3({ project: v2ProjectEmptyScenes }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -55,7 +56,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -91,7 +92,7 @@ describe("V2 to V3 Migration", () => { test("preserves existing metadata fields", () => { const result = transformProjectV2ToV3({ project: v2Project }); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); expect(metadata.id).toBe(v2Project.metadata.id); expect(metadata.name).toBe(v2Project.metadata.name); expect(metadata.thumbnail).toBe(v2Project.metadata.thumbnail); @@ -122,7 +123,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -156,7 +157,7 @@ describe("V2 to V3 Migration", () => { }; const result = transformProjectV2ToV3({ project: multiSceneProject }); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); // Duration is from main scene only, not sum of all scenes expect(metadata.duration).toBe(10); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts b/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts index 3d77a775..2737bfc3 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV20ToV21 } from "../transformers/v20-to-v21"; +import { asRecord } from "./helpers"; const LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY = 50; @@ -27,8 +28,8 @@ describe("V20 to V21 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(21); - const settings = result.project.settings as Record; - const background = settings.background as Record; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe(500); }); @@ -45,8 +46,8 @@ describe("V20 to V21 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record; - const background = settings.background as Record; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe( LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY, ); @@ -65,7 +66,7 @@ describe("V20 to V21 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record; + const settings = asRecord(result.project.settings); expect(settings.background).toEqual({ type: "color", color: "#000000" }); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts index 18b815b0..76d9c0e1 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV21ToV22 } from "../transformers/v21-to-v22"; +import { asRecord, asRecordArray } from "./helpers"; describe("V21 to V22 Migration", () => { test("migrates legacy animation channels to bindings and component channels", () => { @@ -77,12 +78,12 @@ describe("V21 to V22 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(22); - const scenes = result.project.scenes as Array>; - const tracks = scenes[0].tracks as Array>; - const elements = tracks[0].elements as Array>; - const animations = elements[0].animations as Record; - const bindings = animations.bindings as Record>; - const channels = animations.channels as Record>; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecordArray(scenes[0].tracks); + const elements = asRecordArray(tracks[0].elements); + const animations = asRecord(elements[0].animations); + const bindings = asRecord(animations.bindings); + const channels = asRecord(animations.channels); expect(bindings.opacity).toEqual({ path: "opacity", diff --git a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts index 5b764b48..e229ecbc 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV22ToV23 } from "../transformers/v22-to-v23"; +import { asRecord, asRecordArray } from "./helpers"; describe("V22 to V23 Migration", () => { test("converts project time values from seconds to ticks and fps to a frame-rate object", () => { @@ -101,20 +102,17 @@ describe("V22 to V23 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(23); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(1_860_000); - const settings = result.project.settings as Record; + const settings = asRecord(result.project.settings); expect(settings.fps).toEqual({ numerator: 30_000, denominator: 1_001 }); - const timelineViewState = result.project.timelineViewState as Record< - string, - unknown - >; + const timelineViewState = asRecord(result.project.timelineViewState); expect(timelineViewState.playheadTime).toBe(150_000); expect(timelineViewState.scrollLeft).toBe(120); - const scenes = result.project.scenes as Array>; + const scenes = asRecordArray(result.project.scenes); const scene = scenes[0]; expect(scene.bookmarks).toEqual([ { @@ -126,8 +124,8 @@ describe("V22 to V23 Migration", () => { { time: 540_000 }, ]); - const tracks = scene.tracks as Array>; - const elements = tracks[0].elements as Array>; + const tracks = asRecordArray(scene.tracks); + const elements = asRecordArray(tracks[0].elements); const element = elements[0]; expect(element.startTime).toBe(150_000); expect(element.duration).toBe(660_000); @@ -135,8 +133,8 @@ describe("V22 to V23 Migration", () => { expect(element.trimEnd).toBe(60_000); expect(element.sourceDuration).toBe(750_000); - const animations = element.animations as Record; - const channels = animations.channels as Record>; + const animations = asRecord(element.animations); + const channels = asRecord(animations.channels); expect(channels["opacity:value"]).toEqual({ kind: "scalar", keys: [ diff --git a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts index 730d958d..5e16e886 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV26ToV27 } from "../transformers/v26-to-v27"; +import { asRecord, asRecordArray } from "./helpers"; describe("V26 to V27 Migration", () => { test("converts custom mask paths from JSON strings to typed point arrays", () => { @@ -59,13 +60,13 @@ describe("V26 to V27 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(27); - const scenes = result.project.scenes as Array>; - const tracks = scenes[0].tracks as Record; - const mainTrack = tracks.main as Record; - const elements = mainTrack.elements as Array>; - const masks = elements[0].masks as Array>; - const customParams = masks[0].params as Record; - const rectangleParams = masks[1].params as Record; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecord(scenes[0].tracks); + const mainTrack = asRecord(tracks.main); + const elements = asRecordArray(mainTrack.elements); + const masks = asRecordArray(elements[0].masks); + const customParams = asRecord(masks[0].params); + const rectangleParams = asRecord(masks[1].params); expect(customParams.path).toEqual([ { diff --git a/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts index d467c8a1..cb09e07d 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV27ToV28 } from "../transformers/v27-to-v28"; +import { asRecord, asRecordArray } from "./helpers"; describe("V27 to V28 Migration", () => { test("rounds persisted media-time floats back to integer ticks", () => { @@ -96,18 +97,15 @@ describe("V27 to V28 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(28); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(2_152_466); - const timelineViewState = result.project.timelineViewState as Record< - string, - unknown - >; + const timelineViewState = asRecord(result.project.timelineViewState); expect(timelineViewState.playheadTime).toBe(301_235); expect(timelineViewState.zoomLevel).toBe(1.25); expect(timelineViewState.scrollLeft).toBe(120); - const scenes = result.project.scenes as Array>; + const scenes = asRecordArray(result.project.scenes); const scene = scenes[0]; expect(scene.bookmarks).toEqual([ { @@ -118,9 +116,9 @@ describe("V27 to V28 Migration", () => { }, ]); - const tracks = scene.tracks as Record; - const mainTrack = tracks.main as Record; - const elements = mainTrack.elements as Array>; + const tracks = asRecord(scene.tracks); + const mainTrack = asRecord(tracks.main); + const elements = asRecordArray(mainTrack.elements); const element = elements[0]; expect(element.startTime).toBe(300_000); expect(element.duration).toBe(2_152_466); @@ -128,9 +126,9 @@ describe("V27 to V28 Migration", () => { expect(element.trimEnd).toBe(15_001); expect(element.sourceDuration).toBe(2_197_468); - const animations = element.animations as Record; - const channels = animations.channels as Record>; - const opacityChannel = channels.opacity; + const animations = asRecord(element.animations); + const channels = asRecord(animations.channels); + const opacityChannel = asRecord(channels.opacity); expect(opacityChannel.keys).toEqual([ { id: "key-1", @@ -204,26 +202,23 @@ describe("V27 to V28 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(28); - const metadata = result.project.metadata as Record; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(120_000); - const timelineViewState = result.project.timelineViewState as Record< - string, - unknown - >; + const timelineViewState = asRecord(result.project.timelineViewState); expect(timelineViewState).toEqual({ zoomLevel: 2, scrollLeft: 300, playheadTime: 30_000, }); - const scenes = result.project.scenes as Array>; + const scenes = asRecordArray(result.project.scenes); const scene = scenes[0]; expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]); - const tracks = scene.tracks as Record; - const mainTrack = tracks.main as Record; - const element = (mainTrack.elements as Array>)[0]; + const tracks = asRecord(scene.tracks); + const mainTrack = asRecord(tracks.main); + const element = asRecordArray(mainTrack.elements)[0]; expect(element.startTime).toBe(10_000); expect(element.duration).toBe(20_000); expect(element.trimStart).toBe(1_000); 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 6c2d6597..e5ad7e34 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,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV3ToV4 } from "../transformers/v3-to-v4"; import { v3Project } from "./fixtures"; +import { asRecordArray } from "./helpers"; describe("V3 to V4 Migration", () => { test("normalizes legacy text fontWeight values", () => { @@ -54,15 +55,9 @@ describe("V3 to V4 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(4); - const migratedScene = ( - result.project.scenes as Array> - )[0]; - const migratedTrack = ( - migratedScene.tracks as Array> - )[0]; - const migratedElement = ( - migratedTrack.elements as Array> - )[0]; + const migratedScene = asRecordArray(result.project.scenes)[0]; + const migratedTrack = asRecordArray(migratedScene.tracks)[0]; + const migratedElement = asRecordArray(migratedTrack.elements)[0]; expect(migratedElement.fontWeight).toBe("700"); }); @@ -104,15 +99,9 @@ describe("V3 to V4 Migration", () => { }; const result = transformProjectV3ToV4({ project: projectWithoutTextTrack }); - const migratedScene = ( - result.project.scenes as Array> - )[0]; - const migratedTrack = ( - migratedScene.tracks as Array> - )[0]; - const migratedElement = ( - migratedTrack.elements as Array> - )[0]; + const migratedScene = asRecordArray(result.project.scenes)[0]; + const migratedTrack = asRecordArray(migratedScene.tracks)[0]; + const migratedElement = asRecordArray(migratedTrack.elements)[0]; expect(migratedElement.iconName).toBe("mdi:home"); }); 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 95386137..16e9e7ca 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,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV4ToV5 } from "../transformers/v4-to-v5"; import { v3Project } from "./fixtures"; +import { asRecordArray } from "./helpers"; describe("V4 to V5 Migration", () => { test("migrates sticker iconName to stickerId and removes legacy color", () => { @@ -48,15 +49,9 @@ describe("V4 to V5 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(5); - const migratedScene = ( - result.project.scenes as Array> - )[0]; - const migratedTrack = ( - migratedScene.tracks as Array> - )[0]; - const migratedElement = ( - migratedTrack.elements as Array> - )[0]; + const migratedScene = asRecordArray(result.project.scenes)[0]; + const migratedTrack = asRecordArray(migratedScene.tracks)[0]; + const migratedElement = asRecordArray(migratedTrack.elements)[0]; expect(migratedElement.stickerId).toBe("icons:mdi:home"); expect("iconName" in migratedElement).toBe(false); @@ -101,15 +96,9 @@ describe("V4 to V5 Migration", () => { }; const result = transformProjectV4ToV5({ project: projectWithStickerId }); - const migratedScene = ( - result.project.scenes as Array> - )[0]; - const migratedTrack = ( - migratedScene.tracks as Array> - )[0]; - const migratedElement = ( - migratedTrack.elements as Array> - )[0]; + const migratedScene = asRecordArray(result.project.scenes)[0]; + const migratedTrack = asRecordArray(migratedScene.tracks)[0]; + const migratedElement = asRecordArray(migratedTrack.elements)[0]; expect(migratedElement.stickerId).toBe("flags:AD"); }); 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 b05d1837..8fd267fd 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,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; import { v5Project } from "./fixtures"; +import { asRecordArray } from "./helpers"; describe("V5 to V6 Migration", () => { test("converts number bookmarks to Bookmark objects", async () => { @@ -13,19 +14,13 @@ describe("V5 to V6 Migration", () => { 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([ + const scenes = asRecordArray(result.project.scenes); + expect(scenes[0].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([]); + expect(scenes[1].bookmarks).toEqual([]); }); test("skips projects that are already v6", () => { @@ -83,9 +78,7 @@ describe("V5 to V6 Migration", () => { }); expect(result.skipped).toBe(false); - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; + const mainScene = asRecordArray(result.project.scenes)[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 aa79a6ea..ce8e9bd3 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,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV8ToV9 } from "../transformers/v8-to-v9"; +import { asRecord, asRecordArray } from "./helpers"; const v8ProjectWithText = { id: "project-v8-text", @@ -70,16 +71,16 @@ describe("V8 to V9 Migration", () => { 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 } }>; + const track = asRecordArray(asRecordArray(result.project.scenes)[0].tracks)[0]; + const elements = asRecordArray(track.elements); + const background0 = asRecord(elements[0].background); + const background1 = asRecord(elements[1].background); - expect(elements[0].background.enabled).toBe(true); - expect(elements[0].background.color).toBe("#ff0000"); + expect(background0.enabled).toBe(true); + expect(background0.color).toBe("#ff0000"); - expect(elements[1].background.enabled).toBe(false); - expect(elements[1].background.color).toBe("transparent"); + expect(background1.enabled).toBe(false); + expect(background1.color).toBe("transparent"); }); test("preserves existing background.enabled if already present", () => { @@ -87,7 +88,7 @@ describe("V8 to V9 Migration", () => { ...v8ProjectWithText, scenes: [ { - ...(v8ProjectWithText.scenes as Record[])[0], + ...asRecordArray(v8ProjectWithText.scenes)[0], tracks: [ { id: "track-text", @@ -116,10 +117,9 @@ describe("V8 to V9 Migration", () => { 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); + const track = asRecordArray(asRecordArray(result.project.scenes)[0].tracks)[0]; + const elements = asRecordArray(track.elements); + expect(asRecord(elements[0].background).enabled).toBe(false); }); test("skips non-text elements and tracks", () => { diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts index 065a6b34..e9e23c4e 100644 --- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -19,34 +19,6 @@ const EMPTY_V1_TO_V2_CONTEXT: V1ToV2Context = { mediaTypesById: {}, }; -interface LegacyMediaElement { - type: "media"; - mediaId: string; - muted?: boolean; - [key: string]: unknown; -} - -interface LegacyTextElement { - type: "text"; - x: number; - y: number; - rotation: number; - opacity: number; - [key: string]: unknown; -} - -interface LegacyAudioElement { - type: "audio"; - mediaId: string; - [key: string]: unknown; -} - -interface LegacyMediaTrack { - type: "media"; - elements: unknown[]; - [key: string]: unknown; -} - interface V2Transform { scale: number; position: { x: number; y: number }; @@ -315,7 +287,7 @@ function transformTracks({ const isMain = !isFirstVideoTrackFound; isFirstVideoTrackFound = true; const videoTrack = transformMediaTrack({ - track: track as LegacyMediaTrack, + track, context, isMain, }); @@ -343,7 +315,7 @@ function transformMediaTrack({ context, isMain, }: { - track: LegacyMediaTrack; + track: Record; context: V1ToV2Context; isMain: boolean; }): V2VideoTrack { @@ -354,8 +326,7 @@ function transformMediaTrack({ return null; } - const mediaElement = element as LegacyMediaElement; - const mediaId = getStringValue({ value: mediaElement.mediaId }); + const mediaId = getStringValue({ value: element.mediaId }); if (!mediaId) { return null; } @@ -372,7 +343,7 @@ function transformMediaTrack({ rotate: 0, }; - const muted = mediaElement.muted === true; + const muted = element.muted === true; if (mediaType === "image") { const imageElement: V2ImageElement = { @@ -442,15 +413,14 @@ function transformTextTrack({ return null; } - const textElement = element as LegacyTextElement; - const x = getNumberValue({ value: textElement.x, fallback: 0 }); - const y = getNumberValue({ value: textElement.y, fallback: 0 }); + const x = getNumberValue({ value: element.x, fallback: 0 }); + const y = getNumberValue({ value: element.y, fallback: 0 }); const rotation = getNumberValue({ - value: textElement.rotation, + value: element.rotation, fallback: 0, }); const opacity = getNumberValue({ - value: textElement.opacity, + value: element.opacity, fallback: 1, }); @@ -464,23 +434,23 @@ function transformTextTrack({ id: getStringValue({ value: element.id, fallback: "" }), name: getStringValue({ value: element.name, fallback: "" }), type: "text", - content: getStringValue({ value: textElement.content, fallback: "" }), + content: getStringValue({ value: element.content, fallback: "" }), fontSize: getNumberValue({ - value: textElement.fontSize, + value: element.fontSize, fallback: 16, }), fontFamily: getStringValue({ - value: textElement.fontFamily, + value: element.fontFamily, fallback: "Arial", }), color: getStringValue({ - value: textElement.color, + value: element.color, fallback: "#000000", }), background: { enabled: false, color: getStringValue({ - value: textElement.backgroundColor, + value: element.backgroundColor, fallback: "transparent", }), cornerRadius: 0, @@ -489,22 +459,26 @@ function transformTextTrack({ offsetX: 0, offsetY: 0, }, - textAlign: (getStringValue({ - value: textElement.textAlign, + textAlign: parseEnum({ + value: element.textAlign, + allowed: ["left", "center", "right"] as const, fallback: "left", - }) || "left") as "left" | "center" | "right", - fontWeight: (getStringValue({ - value: textElement.fontWeight, + }), + fontWeight: parseEnum({ + value: element.fontWeight, + allowed: ["normal", "bold"] as const, fallback: "normal", - }) || "normal") as "normal" | "bold", - fontStyle: (getStringValue({ - value: textElement.fontStyle, + }), + fontStyle: parseEnum({ + value: element.fontStyle, + allowed: ["normal", "italic"] as const, fallback: "normal", - }) || "normal") as "normal" | "italic", - textDecoration: (getStringValue({ - value: textElement.textDecoration, + }), + textDecoration: parseEnum({ + value: element.textDecoration, + allowed: ["none", "underline", "line-through"] as const, fallback: "none", - }) || "none") as "none" | "underline" | "line-through", + }), hidden: false, transform, opacity, @@ -538,8 +512,7 @@ function transformAudioTrack({ return null; } - const audioElement = element as LegacyAudioElement; - const mediaId = getStringValue({ value: audioElement.mediaId }); + const mediaId = getStringValue({ value: element.mediaId }); if (!mediaId) { return null; } @@ -762,6 +735,23 @@ function getStringValue({ return fallback; } +function parseEnum({ + value, + allowed, + fallback, +}: { + value: unknown; + allowed: readonly T[]; + fallback: T; +}): T { + for (const candidate of allowed) { + if (value === candidate) { + return candidate; + } + } + return fallback; +} + function normalizeDateString({ value }: { value: unknown }): string { if (value instanceof Date) { return value.toISOString(); 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 89dde38e..6e152adb 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 @@ -94,10 +94,7 @@ function migrateSplitMask({ mask }: { mask: unknown }): unknown { 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 }; + const { position: _removed, ...restParams } = params; return { ...mask, diff --git a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts index 3b118ee7..4477d363 100644 --- a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts +++ b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts @@ -12,19 +12,18 @@ interface CustomMaskPathPoint { } function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { - if (!value || typeof value !== "object") { + if (!isRecord(value)) { return false; } - const candidate = value as Record; return ( - typeof candidate.id === "string" && - typeof candidate.x === "number" && - typeof candidate.y === "number" && - typeof candidate.inX === "number" && - typeof candidate.inY === "number" && - typeof candidate.outX === "number" && - typeof candidate.outY === "number" + typeof value.id === "string" && + typeof value.x === "number" && + typeof value.y === "number" && + typeof value.inX === "number" && + typeof value.inY === "number" && + typeof value.outX === "number" && + typeof value.outY === "number" ); } 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 84d97fd8..7a28134b 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,10 +1,10 @@ import type { MigrationResult, ProjectRecord } from "./types"; import { getProjectId, isRecord } from "./utils"; -const LEGACY_FONT_WEIGHT_MAP = { - normal: "400", - bold: "700", -} as const; +const LEGACY_FONT_WEIGHT_MAP = new Map([ + ["normal", "400"], + ["bold", "700"], +]); const VALID_NUMERIC_FONT_WEIGHTS = new Set([ "100", @@ -168,10 +168,9 @@ function normalizeFontWeight({ value }: { value: unknown }): unknown { } 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 - ]; + const mapped = LEGACY_FONT_WEIGHT_MAP.get(normalized); + if (mapped !== undefined) { + return mapped; } if (VALID_NUMERIC_FONT_WEIGHTS.has(normalized)) { diff --git a/apps/web/src/services/storage/opfs-adapter.ts b/apps/web/src/services/storage/opfs-adapter.ts index 879223b5..220ee452 100644 --- a/apps/web/src/services/storage/opfs-adapter.ts +++ b/apps/web/src/services/storage/opfs-adapter.ts @@ -27,7 +27,13 @@ export class OPFSAdapter implements StorageAdapter { } } - async set(key: string, file: File): Promise { + async set({ + key, + value: file, + }: { + key: string; + value: File; + }): Promise { const directory = await this.getDirectory(); const fileHandle = await directory.getFileHandle(key, { create: true }); const writable = await fileHandle.createWritable(); diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index a388e807..e8fc26c0 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -65,17 +65,17 @@ class StorageService { version: 1, }; - this.projectsAdapter = new IndexedDBAdapter( - this.config.projectsDb, - "projects", - this.config.version, - ); + this.projectsAdapter = new IndexedDBAdapter({ + dbName: this.config.projectsDb, + storeName: "projects", + version: this.config.version, + }); - this.savedSoundsAdapter = new IndexedDBAdapter( - this.config.savedSoundsDb, - "saved-sounds", - this.config.version, - ); + this.savedSoundsAdapter = new IndexedDBAdapter({ + dbName: this.config.savedSoundsDb, + storeName: "saved-sounds", + version: this.config.version, + }); } private async ensureMigrations(): Promise { @@ -91,11 +91,11 @@ class StorageService { } private getProjectMediaAdapters({ projectId }: { projectId: string }) { - const mediaMetadataAdapter = new IndexedDBAdapter( - `${this.config.mediaDb}-${projectId}`, - "media-metadata", - this.config.version, - ); + const mediaMetadataAdapter = new IndexedDBAdapter({ + dbName: `${this.config.mediaDb}-${projectId}`, + storeName: "media-metadata", + version: this.config.version, + }); const mediaAssetsAdapter = new OPFSAdapter(`media-files-${projectId}`); @@ -161,7 +161,10 @@ class StorageService { timelineViewState: project.timelineViewState, }; - await this.projectsAdapter.set(project.metadata.id, serializedProject); + await this.projectsAdapter.set({ + key: project.metadata.id, + value: serializedProject, + }); } async loadProject({ @@ -305,8 +308,14 @@ class StorageService { }; try { - await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file); - await mediaMetadataAdapter.set(mediaAsset.id, metadata); + await mediaAssetsAdapter.set({ + key: mediaAsset.id, + value: mediaAsset.file, + }); + await mediaMetadataAdapter.set({ + key: mediaAsset.id, + value: metadata, + }); } catch (error) { try { await mediaAssetsAdapter.remove(mediaAsset.id); @@ -501,7 +510,10 @@ class StorageService { lastModified: new Date().toISOString(), }; - await this.savedSoundsAdapter.set("user-sounds", updatedData); + await this.savedSoundsAdapter.set({ + key: "user-sounds", + value: updatedData, + }); } catch (error) { console.error("Failed to save sound effect:", error); throw error; @@ -517,7 +529,10 @@ class StorageService { lastModified: new Date().toISOString(), }; - await this.savedSoundsAdapter.set("user-sounds", updatedData); + await this.savedSoundsAdapter.set({ + key: "user-sounds", + value: updatedData, + }); } catch (error) { console.error("Failed to remove saved sound:", error); throw error; diff --git a/apps/web/src/services/storage/types.ts b/apps/web/src/services/storage/types.ts index 58c14b49..f20b311a 100644 --- a/apps/web/src/services/storage/types.ts +++ b/apps/web/src/services/storage/types.ts @@ -8,7 +8,7 @@ import type { TScene } from "@/timeline"; export interface StorageAdapter { get(key: string): Promise; - set(key: string, value: T): Promise; + set(args: { key: string; value: T }): Promise; remove(key: string): Promise; list(): Promise; clear(): Promise; -- cgit v1.3.1