diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-27 00:55:15 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:56:48 +0200 |
| commit | 4d127a038bf768eded68aae3cfa5c1b12241bdac (patch) | |
| tree | 16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/src/services | |
| parent | 82817cb50cb0487350573d6129130aec705f924b (diff) | |
| download | opencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz | |
chore: normalize line endings
Diffstat (limited to 'apps/web/src/services')
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);
+ }
+}
|
