mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
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
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from "./v0";
|
||||
export * from "./v1";
|
||||
export * from "./v2";
|
||||
export * from "./v3";
|
||||
export * from "./v5";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})) ?? [];
|
||||
|
||||
Reference in New Issue
Block a user