summaryrefslogtreecommitdiff
path: root/apps/web/src/services
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-02-23 03:24:02 +0100
committerGitHub <noreply@github.com>2026-02-23 03:24:02 +0100
commit93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch)
tree1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web/src/services
parentfca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff)
downloadopencut-93d1e3383ce47e45157d70f9ba6251239dae6bc2.tar.gz
feat: major editor overhaul (assets, properties, timeline, fonts) (#709)
* feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env
Diffstat (limited to 'apps/web/src/services')
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts14
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts173
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts7
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts6
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts1
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts54
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts140
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts137
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts92
-rw-r--r--apps/web/src/services/storage/migrations/index.ts8
-rw-r--r--apps/web/src/services/storage/migrations/runner.ts6
-rw-r--r--apps/web/src/services/storage/migrations/transformers/index.ts2
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts189
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts177
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts88
-rw-r--r--apps/web/src/services/storage/migrations/v3-to-v4.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v4-to-v5.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v5-to-v6.ts16
-rw-r--r--apps/web/src/services/storage/service.ts27
19 files changed, 1140 insertions, 29 deletions
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index 9842320e..a2fb0170 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -1,9 +1,9 @@
import type { CanvasRenderer } from "../canvas-renderer";
+import { resolveStickerId } from "@/lib/stickers";
import { VisualNode, type VisualNodeParams } from "./visual-node";
export interface StickerNodeParams extends VisualNodeParams {
- iconName: string;
- color?: string;
+ stickerId: string;
}
export class StickerNode extends VisualNode<StickerNodeParams> {
@@ -18,15 +18,15 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
private async load() {
const image = new Image();
this.image = image;
- const color = this.params.color
- ? `&color=${encodeURIComponent(this.params.color)}`
- : "";
- const url = `https://api.iconify.design/${this.params.iconName}.svg?width=200&height=200${color}`;
+ const url = resolveStickerId({
+ stickerId: this.params.stickerId,
+ options: { width: 200, height: 200 },
+ });
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () =>
- reject(new Error(`Failed to load sticker: ${this.params.iconName}`));
+ reject(new Error(`Failed to load sticker: ${this.params.stickerId}`));
image.src = url;
});
}
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index 121ba834..b7e36d64 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -1,7 +1,10 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
-import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants";
+import {
+ DEFAULT_LINE_HEIGHT,
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
function scaleFontSize({
fontSize,
@@ -13,6 +16,107 @@ function scaleFontSize({
return fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
}
+function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
+ return `"${fontFamily.replace(/"/g, '\\"')}"`;
+}
+
+function getMetricAscent({
+ metrics,
+ fallback,
+}: {
+ metrics: TextMetrics;
+ fallback: number;
+}): number {
+ return metrics.actualBoundingBoxAscent ?? fallback * 0.8;
+}
+
+function getMetricDescent({
+ metrics,
+ fallback,
+}: {
+ metrics: TextMetrics;
+ fallback: number;
+}): number {
+ return metrics.actualBoundingBoxDescent ?? fallback * 0.2;
+}
+
+interface TextBlockMeasurement {
+ visualCenterOffset: number;
+ height: number;
+ maxWidth: number;
+}
+
+function measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize,
+}: {
+ lineMetrics: TextMetrics[];
+ lineHeightPx: number;
+ fallbackFontSize: number;
+}): TextBlockMeasurement {
+ let top = Number.POSITIVE_INFINITY;
+ let bottom = Number.NEGATIVE_INFINITY;
+ let maxWidth = 0;
+
+ for (let i = 0; i < lineMetrics.length; i++) {
+ const metrics = lineMetrics[i];
+ const y = i * lineHeightPx;
+ top = Math.min(
+ top,
+ y - getMetricAscent({ metrics, fallback: fallbackFontSize }),
+ );
+ bottom = Math.max(
+ bottom,
+ y + getMetricDescent({ metrics, fallback: fallbackFontSize }),
+ );
+ maxWidth = Math.max(maxWidth, metrics.width);
+ }
+
+ const height = bottom - top;
+ const visualCenterOffset = (top + bottom) / 2;
+
+ return { visualCenterOffset, height, maxWidth };
+}
+
+function drawTextDecoration({
+ ctx,
+ textDecoration,
+ lineWidth,
+ lineY,
+ metrics,
+ scaledFontSize,
+ textAlign,
+}: {
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ textDecoration: string;
+ lineWidth: number;
+ lineY: number;
+ metrics: TextMetrics;
+ scaledFontSize: number;
+ textAlign: CanvasTextAlign;
+}): void {
+ if (textDecoration === "none" || !textDecoration) return;
+
+ const thickness = Math.max(1, scaledFontSize * 0.07);
+ const ascent = getMetricAscent({ metrics, fallback: scaledFontSize });
+ const descent = getMetricDescent({ metrics, fallback: scaledFontSize });
+
+ let xStart = -lineWidth / 2;
+ if (textAlign === "left") xStart = 0;
+ if (textAlign === "right") xStart = -lineWidth;
+
+ if (textDecoration === "underline") {
+ const underlineY = lineY + descent + thickness;
+ ctx.fillRect(xStart, underlineY, lineWidth, thickness);
+ }
+
+ if (textDecoration === "line-through") {
+ const strikeY = lineY - (ascent - descent) * 0.35;
+ ctx.fillRect(xStart, strikeY, lineWidth, thickness);
+ }
+}
+
export type TextNodeParams = TextElement & {
canvasCenter: { x: number; y: number };
canvasHeight: number;
@@ -38,6 +142,10 @@ export class TextNode extends BaseNode<TextNodeParams> {
const y = this.params.transform.position.y + this.params.canvasCenter.y;
renderer.context.translate(x, y);
+ renderer.context.scale(
+ this.params.transform.scale,
+ this.params.transform.scale,
+ );
if (this.params.transform.rotate) {
renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180);
}
@@ -48,39 +156,72 @@ export class TextNode extends BaseNode<TextNodeParams> {
fontSize: this.params.fontSize,
canvasHeight: this.params.canvasHeight,
});
- renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${this.params.fontFamily}`;
+ const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
+ renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
renderer.context.textAlign = this.params.textAlign;
- renderer.context.textBaseline = this.params.textBaseline || "middle";
renderer.context.fillStyle = this.params.color;
+ const letterSpacing = this.params.letterSpacing ?? 0;
+ const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
+ if ("letterSpacing" in renderer.context) {
+ (
+ renderer.context as CanvasRenderingContext2D & { letterSpacing: string }
+ ).letterSpacing = `${letterSpacing}px`;
+ }
+
+ const lines = this.params.content.split("\n");
+ const lineHeightPx = scaledFontSize * lineHeight;
+ const baseline = this.params.textBaseline ?? "middle";
+
+ renderer.context.textBaseline = baseline;
+ const lineMetrics = lines.map((line) => renderer.context.measureText(line));
+ const lineCount = lines.length;
+
+ const block = measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize: scaledFontSize,
+ });
+
const prevAlpha = renderer.context.globalAlpha;
+ renderer.context.globalCompositeOperation = (
+ this.params.blendMode && this.params.blendMode !== "normal"
+ ? this.params.blendMode
+ : "source-over"
+ ) as GlobalCompositeOperation;
renderer.context.globalAlpha = this.params.opacity;
- if (this.params.backgroundColor) {
- const metrics = renderer.context.measureText(this.params.content);
- const ascent = metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8;
- const descent = metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2;
- const textW = metrics.width;
- const textH = ascent + descent;
+ if (this.params.backgroundColor && lineCount > 0) {
const padX = 8;
const padY = 4;
-
renderer.context.fillStyle = this.params.backgroundColor;
- let bgLeft = -textW / 2;
+ let bgLeft = -block.maxWidth / 2;
if (renderer.context.textAlign === "left") bgLeft = 0;
- if (renderer.context.textAlign === "right") bgLeft = -textW;
+ if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth;
renderer.context.fillRect(
bgLeft - padX,
- -textH / 2 - padY,
- textW + padX * 2,
- textH + padY * 2,
+ -block.height / 2 - padY,
+ block.maxWidth + padX * 2,
+ block.height + padY * 2,
);
renderer.context.fillStyle = this.params.color;
}
- renderer.context.fillText(this.params.content, 0, 0);
+ for (let i = 0; i < lineCount; i++) {
+ const y = i * lineHeightPx - block.visualCenterOffset;
+ renderer.context.fillText(lines[i], 0, y);
+ drawTextDecoration({
+ ctx: renderer.context,
+ textDecoration: this.params.textDecoration ?? "none",
+ lineWidth: lineMetrics[i].width,
+ lineY: y,
+ metrics: lineMetrics[i],
+ scaledFontSize,
+ textAlign: this.params.textAlign,
+ });
+ }
renderer.context.globalAlpha = prevAlpha;
renderer.context.restore();
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 9c6c88ca..7179369e 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -1,5 +1,6 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
+import type { BlendMode } from "@/types/rendering";
import type { Transform } from "@/types/timeline";
const VISUAL_EPSILON = 1 / 1000;
@@ -11,6 +12,7 @@ export interface VisualNodeParams {
trimEnd: number;
transform: Transform;
opacity: number;
+ blendMode?: BlendMode;
}
export abstract class VisualNode<
@@ -51,6 +53,11 @@ export abstract class VisualNode<
const x = renderer.width / 2 + transform.position.x - scaledWidth / 2;
const y = renderer.height / 2 + transform.position.y - scaledHeight / 2;
+ renderer.context.globalCompositeOperation = (
+ this.params.blendMode && this.params.blendMode !== "normal"
+ ? this.params.blendMode
+ : "source-over"
+ ) as GlobalCompositeOperation;
renderer.context.globalAlpha = opacity;
if (transform.rotate !== 0) {
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index d1b8217e..ee7f3d6c 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -66,6 +66,7 @@ export function buildScene(params: BuildSceneParams) {
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
+ blendMode: element.blendMode,
}),
);
}
@@ -79,6 +80,7 @@ export function buildScene(params: BuildSceneParams) {
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
+ blendMode: element.blendMode,
}),
);
}
@@ -98,14 +100,14 @@ export function buildScene(params: BuildSceneParams) {
if (element.type === "sticker") {
contentNodes.push(
new StickerNode({
- iconName: element.iconName,
+ stickerId: element.stickerId,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
- color: element.color,
+ blendMode: element.blendMode,
}),
);
}
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 ee1485ba..5f82e14a 100644
--- a/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts
@@ -7,3 +7,4 @@ 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
new file mode 100644
index 00000000..5e181c76
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts
@@ -0,0 +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",
+ },
+ ],
+};
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
new file mode 100644
index 00000000..81d03147
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts
@@ -0,0 +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");
+ });
+});
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
new file mode 100644
index 00000000..ce0c3019
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts
@@ -0,0 +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");
+ });
+});
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
new file mode 100644
index 00000000..6bbd5634
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
@@ -0,0 +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 },
+ ]);
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index df0b8007..00c009c7 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -2,13 +2,19 @@ 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";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 3;
+export const CURRENT_PROJECT_VERSION = 6;
export const migrations = [
new V0toV1Migration(),
new V1toV2Migration(),
new V2toV3Migration(),
+ new V3toV4Migration(),
+ new V4toV5Migration(),
+ new V5toV6Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/runner.ts b/apps/web/src/services/storage/migrations/runner.ts
index b261252b..e1c2d09a 100644
--- a/apps/web/src/services/storage/migrations/runner.ts
+++ b/apps/web/src/services/storage/migrations/runner.ts
@@ -136,7 +136,11 @@ function getProjectVersion({ project }: { project: ProjectRecord }): number {
return 0;
}
-function getProjectName({ project }: { project: ProjectRecord }): string | null {
+function getProjectName({
+ project,
+}: {
+ project: ProjectRecord;
+}): string | null {
const metadata = project.metadata;
if (isRecord(metadata) && typeof metadata.name === "string") {
return metadata.name;
diff --git a/apps/web/src/services/storage/migrations/transformers/index.ts b/apps/web/src/services/storage/migrations/transformers/index.ts
index 74cf46a9..8878d6ba 100644
--- a/apps/web/src/services/storage/migrations/transformers/index.ts
+++ b/apps/web/src/services/storage/migrations/transformers/index.ts
@@ -1,4 +1,6 @@
export { transformProjectV0ToV1 } from "./v0-to-v1";
export { transformProjectV1ToV2 } from "./v1-to-v2";
export { transformProjectV2ToV3 } from "./v2-to-v3";
+export { transformProjectV3ToV4 } from "./v3-to-v4";
+export { transformProjectV4ToV5 } from "./v4-to-v5";
export type { MigrationResult, ProjectRecord } from "./types";
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
new file mode 100644
index 00000000..d6e1efee
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts
@@ -0,0 +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;
+}
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
new file mode 100644
index 00000000..971e3510
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts
@@ -0,0 +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;
+}
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
new file mode 100644
index 00000000..ca855149
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts
@@ -0,0 +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;
+}
diff --git a/apps/web/src/services/storage/migrations/v3-to-v4.ts b/apps/web/src/services/storage/migrations/v3-to-v4.ts
new file mode 100644
index 00000000..67b2fdf7
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v3-to-v4.ts
@@ -0,0 +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 });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v4-to-v5.ts b/apps/web/src/services/storage/migrations/v4-to-v5.ts
new file mode 100644
index 00000000..829a746d
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v4-to-v5.ts
@@ -0,0 +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 });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v5-to-v6.ts b/apps/web/src/services/storage/migrations/v5-to-v6.ts
new file mode 100644
index 00000000..b621bd72
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v5-to-v6.ts
@@ -0,0 +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 });
+ }
+}
diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts
index 02f8d290..aac152da 100644
--- a/apps/web/src/services/storage/service.ts
+++ b/apps/web/src/services/storage/service.ts
@@ -14,7 +14,30 @@ import {
migrations,
runStorageMigrations,
} from "@/services/storage/migrations";
-import type { TimelineTrack, TScene } from "@/types/timeline";
+import type { Bookmark, TimelineTrack, TScene } from "@/types/timeline";
+
+function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
+ if (!Array.isArray(raw)) return [];
+ return raw
+ .map((item): Bookmark | null => {
+ if (typeof item === "number") return { time: item };
+ const obj = item as Record<string, unknown>;
+ if (
+ typeof obj !== "object" ||
+ obj === null ||
+ typeof obj.time !== "number"
+ ) {
+ return null;
+ }
+ return {
+ time: obj.time,
+ ...(typeof obj.note === "string" && { note: obj.note }),
+ ...(typeof obj.color === "string" && { color: obj.color }),
+ ...(typeof obj.duration === "number" && { duration: obj.duration }),
+ };
+ })
+ .filter((b): b is Bookmark => b !== null);
+}
class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
@@ -137,7 +160,7 @@ class StorageService {
? { ...track, isMain: track.isMain ?? false } // legacy: isMain was optional
: track,
),
- bookmarks: scene.bookmarks ?? [],
+ bookmarks: normalizeBookmarks({ raw: scene.bookmarks }),
createdAt: new Date(scene.createdAt),
updatedAt: new Date(scene.updatedAt),
})) ?? [];