feat: separate audio from elements

This commit is contained in:
Maze Winther
2026-03-31 23:23:36 +02:00
parent d23be079cc
commit f1d5932c54
18 changed files with 915 additions and 53 deletions
@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV19ToV20 } from "../transformers/v19-to-v20";
describe("V19 to V20 Migration", () => {
test("backfills source audio enabled state on video elements", () => {
const result = transformProjectV19ToV20({
project: {
id: "project-v19-source-audio",
version: 19,
metadata: {
id: "project-v19-source-audio",
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",
isMain: true,
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
tracks: [
{
id: "track-video",
type: "video",
name: "Video",
isMain: true,
muted: false,
hidden: false,
elements: [
{
id: "video-1",
type: "video",
name: "Clip",
mediaId: "media-1",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
position: { x: 0, y: 0 },
scale: { x: 1, y: 1 },
rotation: 0,
},
opacity: 1,
},
],
},
{
id: "track-audio",
type: "audio",
name: "Audio",
muted: false,
elements: [
{
id: "audio-1",
type: "audio",
sourceType: "upload",
mediaId: "media-audio-1",
name: "Audio",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
volume: 0,
},
],
},
],
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(20);
expect(
((result.project.scenes as Array<Record<string, unknown>>)[0]
.tracks as Array<Record<string, unknown>>)[0].elements,
).toEqual([
expect.objectContaining({
id: "video-1",
isSourceAudioEnabled: true,
}),
]);
expect(
(((result.project.scenes as Array<Record<string, unknown>>)[0]
.tracks as Array<Record<string, unknown>>)[1]
.elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled,
).toBeUndefined();
});
test("preserves existing explicit source audio state", () => {
const result = transformProjectV19ToV20({
project: {
id: "project-v19-existing-state",
version: 19,
scenes: [
{
tracks: [
{
elements: [
{
type: "video",
isSourceAudioEnabled: false,
},
],
},
],
},
],
},
});
expect(
(((result.project.scenes as Array<Record<string, unknown>>)[0]
.tracks as Array<Record<string, unknown>>)[0]
.elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled,
).toBe(false);
});
test("skips projects already on v20", () => {
const result = transformProjectV19ToV20({
project: {
id: "project-v20",
version: 20,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v20");
});
});
@@ -18,10 +18,11 @@ import { V15toV16Migration } from "./v15-to-v16";
import { V16toV17Migration } from "./v16-to-v17";
import { V17toV18Migration } from "./v17-to-v18";
import { V18toV19Migration } from "./v18-to-v19";
import { V19toV20Migration } from "./v19-to-v20";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 19;
export const CURRENT_PROJECT_VERSION = 20;
export const migrations = [
new V0toV1Migration(),
@@ -43,4 +44,5 @@ export const migrations = [
new V16toV17Migration(),
new V17toV18Migration(),
new V18toV19Migration(),
new V19toV20Migration(),
];
@@ -0,0 +1,87 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV19ToV20({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 20) {
return { project, skipped: true, reason: "already v20" };
}
return {
project: {
...migrateVideoSourceAudioState({ project }),
version: 20,
},
skipped: false,
};
}
function migrateVideoSourceAudioState({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
if (!Array.isArray(project.scenes)) {
return project;
}
return {
...project,
scenes: project.scenes.map((scene) => migrateScene({ scene })),
};
}
function migrateScene({
scene,
}: {
scene: unknown;
}): unknown {
if (!isRecord(scene) || !Array.isArray(scene.tracks)) {
return scene;
}
return {
...scene,
tracks: scene.tracks.map((track) => migrateTrack({ track })),
};
}
function migrateTrack({
track,
}: {
track: unknown;
}): unknown {
if (!isRecord(track) || !Array.isArray(track.elements)) {
return track;
}
return {
...track,
elements: track.elements.map((element) => migrateElement({ element })),
};
}
function migrateElement({
element,
}: {
element: unknown;
}): unknown {
if (!isRecord(element) || element.type !== "video") {
return element;
}
return {
...element,
isSourceAudioEnabled:
typeof element.isSourceAudioEnabled === "boolean"
? element.isSourceAudioEnabled
: true,
};
}
@@ -0,0 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV19ToV20 } from "./transformers/v19-to-v20";
export class V19toV20Migration extends StorageMigration {
from = 19;
to = 20;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV19ToV20({ project });
}
}