summaryrefslogtreecommitdiff
path: root/apps/web/src/services/storage/service.ts
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-27 01:21:20 +0200
committercashmere <cashmere@autistici.org>2026-07-27 01:21:20 +0200
commitd55d2583a71ae35fa8d0d1bfbec975e67e66aafa (patch)
treec7a612f24b12c8602d7fbf139ac2709e4f2f4d9c /apps/web/src/services/storage/service.ts
parent2a372fdec256b8be890e181c221c2a012ead9efa (diff)
downloadopencut-d55d2583a71ae35fa8d0d1bfbec975e67e66aafa.tar.gz
feat: git-like project versioning (v1)
Linear per-project history per notes/project-versioning.md: named checkpoints (thumbnail via renderer.captureFrame) plus auto-checkpoints on save (60s throttle, ring of 20, named never rotate). Restore writes a safety checkpoint first, then swaps the document outside undo/redo. Media assets referenced by any version are pinned against deletion. Migration v31-v32 stamps the schema; the version store lives in its own IDB database with an in-memory fallback for tests. Versions popover in the editor header for jump/rename/delete. 12 new tests.
Diffstat (limited to 'apps/web/src/services/storage/service.ts')
-rw-r--r--apps/web/src/services/storage/service.ts158
1 files changed, 87 insertions, 71 deletions
diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts
index e8fc26c0..7b01f9e5 100644
--- a/apps/web/src/services/storage/service.ts
+++ b/apps/web/src/services/storage/service.ts
@@ -51,6 +51,91 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
.filter((b): b is Bookmark => b !== null);
}
+function stripAudioBuffers({ tracks }: { tracks: SceneTracks }): SceneTracks {
+ return {
+ ...tracks,
+ audio: tracks.audio.map((track) => ({
+ ...track,
+ elements: track.elements.map((element) => {
+ const { buffer: _buffer, ...rest } = element;
+ return rest;
+ }),
+ })),
+ };
+}
+
+export function serializeProject({
+ project,
+}: {
+ project: TProject;
+}): SerializedProject {
+ const duration =
+ project.metadata.duration ??
+ getProjectDurationFromScenes({ scenes: project.scenes });
+ const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
+ id: scene.id,
+ name: scene.name,
+ isMain: scene.isMain,
+ tracks: stripAudioBuffers({ tracks: scene.tracks }),
+ bookmarks: scene.bookmarks,
+ createdAt: scene.createdAt.toISOString(),
+ updatedAt: scene.updatedAt.toISOString(),
+ }));
+
+ return {
+ metadata: {
+ id: project.metadata.id,
+ name: project.metadata.name,
+ thumbnail: project.metadata.thumbnail,
+ duration,
+ createdAt: project.metadata.createdAt.toISOString(),
+ updatedAt: project.metadata.updatedAt.toISOString(),
+ },
+ scenes: serializedScenes,
+ currentSceneId: project.currentSceneId,
+ settings: project.settings,
+ version: project.version,
+ timelineViewState: project.timelineViewState,
+ };
+}
+
+export function deserializeProject({
+ serialized,
+}: {
+ serialized: SerializedProject;
+}): TProject {
+ const scenes =
+ serialized.scenes?.map((scene) => ({
+ id: scene.id,
+ name: scene.name,
+ isMain: scene.isMain,
+ tracks: scene.tracks,
+ bookmarks: normalizeBookmarks({ raw: scene.bookmarks }),
+ createdAt: new Date(scene.createdAt),
+ updatedAt: new Date(scene.updatedAt),
+ })) ?? [];
+
+ return {
+ metadata: {
+ id: serialized.metadata.id,
+ name: serialized.metadata.name,
+ thumbnail: serialized.metadata.thumbnail,
+ duration: roundMediaTime({
+ time:
+ serialized.metadata.duration ??
+ getProjectDurationFromScenes({ scenes }),
+ }),
+ createdAt: new Date(serialized.metadata.createdAt),
+ updatedAt: new Date(serialized.metadata.updatedAt),
+ },
+ scenes,
+ currentSceneId: serialized.currentSceneId || "",
+ settings: serialized.settings,
+ version: serialized.version,
+ timelineViewState: serialized.timelineViewState,
+ };
+}
+
class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
private savedSoundsAdapter: IndexedDBAdapter<SavedSoundsData>;
@@ -118,48 +203,8 @@ class StorageService {
return isStorageQuotaExceededError({ error });
}
- private stripAudioBuffers({ tracks }: { tracks: SceneTracks }): SceneTracks {
- return {
- ...tracks,
- audio: tracks.audio.map((track) => ({
- ...track,
- elements: track.elements.map((element) => {
- const { buffer: _buffer, ...rest } = element;
- return rest;
- }),
- })),
- };
- }
-
async saveProject({ project }: { project: TProject }): Promise<void> {
- const duration =
- project.metadata.duration ??
- getProjectDurationFromScenes({ scenes: project.scenes });
- const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
- id: scene.id,
- name: scene.name,
- isMain: scene.isMain,
- tracks: this.stripAudioBuffers({ tracks: scene.tracks }),
- bookmarks: scene.bookmarks,
- createdAt: scene.createdAt.toISOString(),
- updatedAt: scene.updatedAt.toISOString(),
- }));
-
- const serializedProject: SerializedProject = {
- metadata: {
- id: project.metadata.id,
- name: project.metadata.name,
- thumbnail: project.metadata.thumbnail,
- duration,
- createdAt: project.metadata.createdAt.toISOString(),
- updatedAt: project.metadata.updatedAt.toISOString(),
- },
- scenes: serializedScenes,
- currentSceneId: project.currentSceneId,
- settings: project.settings,
- version: project.version,
- timelineViewState: project.timelineViewState,
- };
+ const serializedProject = serializeProject({ project });
await this.projectsAdapter.set({
key: project.metadata.id,
@@ -190,36 +235,7 @@ class StorageService {
return null;
}
- const scenes =
- serializedProject.scenes?.map((scene) => ({
- id: scene.id,
- name: scene.name,
- isMain: scene.isMain,
- tracks: scene.tracks,
- bookmarks: normalizeBookmarks({ raw: scene.bookmarks }),
- createdAt: new Date(scene.createdAt),
- updatedAt: new Date(scene.updatedAt),
- })) ?? [];
-
- const project: TProject = {
- metadata: {
- id: serializedProject.metadata.id,
- name: serializedProject.metadata.name,
- thumbnail: serializedProject.metadata.thumbnail,
- duration: roundMediaTime({
- time:
- serializedProject.metadata.duration ??
- getProjectDurationFromScenes({ scenes }),
- }),
- createdAt: new Date(serializedProject.metadata.createdAt),
- updatedAt: new Date(serializedProject.metadata.updatedAt),
- },
- scenes,
- currentSceneId: serializedProject.currentSceneId || "",
- settings: serializedProject.settings,
- version: serializedProject.version,
- timelineViewState: serializedProject.timelineViewState,
- };
+ const project = deserializeProject({ serialized: serializedProject });
return { project };
}