summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/src/commands/media/remove-media-asset.ts16
-rw-r--r--apps/web/src/core/index.ts3
-rw-r--r--apps/web/src/core/managers/save-manager.ts4
-rw-r--r--apps/web/src/editor/use-editor.ts1
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v31-to-v32.test.ts65
-rw-r--r--apps/web/src/services/storage/migrations/index.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v31-to-v32.ts31
-rw-r--r--apps/web/src/services/storage/migrations/v31-to-v32.ts14
-rw-r--r--apps/web/src/services/storage/service.ts158
-rw-r--r--apps/web/src/versions/__tests__/media-pin.test.ts137
-rw-r--r--apps/web/src/versions/__tests__/versions-manager.test.ts242
-rw-r--r--apps/web/src/versions/components/version-name-dialog.tsx77
-rw-r--r--apps/web/src/versions/components/versions-popover.tsx320
-rw-r--r--apps/web/src/versions/index.ts13
-rw-r--r--apps/web/src/versions/media-references.ts52
-rw-r--r--apps/web/src/versions/store.ts86
-rw-r--r--apps/web/src/versions/types.ts21
-rw-r--r--apps/web/src/versions/versions-manager.ts503
-rw-r--r--apps/web/src/versions/versions-store.ts23
19 files changed, 1698 insertions, 72 deletions
diff --git a/apps/web/src/commands/media/remove-media-asset.ts b/apps/web/src/commands/media/remove-media-asset.ts
index 38ff79fe..ab267f36 100644
--- a/apps/web/src/commands/media/remove-media-asset.ts
+++ b/apps/web/src/commands/media/remove-media-asset.ts
@@ -1,5 +1,6 @@
import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
+import { toast } from "sonner";
import type { MediaAsset } from "@/media/types";
import { buildWaveformSourceKey } from "@/media/waveform-summary";
import { storageService } from "@/services/storage/service";
@@ -30,6 +31,21 @@ export class RemoveMediaAssetCommand extends Command {
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
+
+ // Media pinning: a stored project version may still reference this
+ // asset; deleting it would break restores (notes/project-versioning.md).
+ if (
+ editor.versions.isMediaIdPinned({
+ projectId: this.projectId,
+ mediaId: this.assetId,
+ })
+ ) {
+ toast.error("Media is used in a saved version", {
+ description: "Delete the checkpoints that reference it first.",
+ });
+ return;
+ }
+
const assets = editor.media.getAssets();
this.savedAssets = [...assets];
diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts
index 0977666f..40352737 100644
--- a/apps/web/src/core/index.ts
+++ b/apps/web/src/core/index.ts
@@ -10,6 +10,7 @@ import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
import { ClipboardManager } from "./managers/clipboard-manager";
import { DiagnosticsManager } from "./managers/diagnostics-manager";
+import { VersionsManager } from "@/versions";
import { registerDefaultEffects } from "@/effects";
import { registerDefaultMasks } from "@/masks";
import { registerTranscriptionDiagnostics } from "@/transcription/diagnostics";
@@ -28,6 +29,7 @@ export class EditorCore {
public readonly selection: SelectionManager;
public readonly clipboard: ClipboardManager;
public readonly diagnostics: DiagnosticsManager;
+ public readonly versions: VersionsManager;
private constructor() {
registerDefaultEffects();
@@ -44,6 +46,7 @@ export class EditorCore {
this.selection = new SelectionManager(this);
this.clipboard = new ClipboardManager(this);
this.diagnostics = new DiagnosticsManager(this);
+ this.versions = new VersionsManager({ editor: this });
registerTranscriptionDiagnostics({ diagnostics: this.diagnostics });
this.playback.bindTimelineScope();
this.command.registerReactor(() => {
diff --git a/apps/web/src/core/managers/save-manager.ts b/apps/web/src/core/managers/save-manager.ts
index 8cb2275a..26d8b323 100644
--- a/apps/web/src/core/managers/save-manager.ts
+++ b/apps/web/src/core/managers/save-manager.ts
@@ -96,6 +96,10 @@ export class SaveManager {
try {
await this.editor.project.saveCurrentProject();
+ // Auto-checkpoint on every successful save; throttled internally.
+ this.editor.versions.writeAutoCheckpoint().catch((error) => {
+ console.error("Failed to write auto-checkpoint:", error);
+ });
} finally {
this.isSaving = false;
if (this.hasPendingSave) {
diff --git a/apps/web/src/editor/use-editor.ts b/apps/web/src/editor/use-editor.ts
index 6380d470..f004f53e 100644
--- a/apps/web/src/editor/use-editor.ts
+++ b/apps/web/src/editor/use-editor.ts
@@ -38,6 +38,7 @@ export function useEditor<T>(
editor.selection.subscribe(onChange),
editor.clipboard.subscribe(onChange),
editor.diagnostics.subscribe(onChange),
+ editor.versions.subscribe(onChange),
];
return () => {
unsubscribers.forEach((unsubscribe) => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v31-to-v32.test.ts b/apps/web/src/services/storage/migrations/__tests__/v31-to-v32.test.ts
new file mode 100644
index 00000000..e3f1af8c
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v31-to-v32.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV31ToV32 } from "../transformers/v31-to-v32";
+import { asRecord, asRecordArray } from "./helpers";
+
+describe("V31 to V32 Migration", () => {
+ test("stamps a v31 project as v32 without changing its content", () => {
+ const result = transformProjectV31ToV32({
+ project: {
+ id: "project-v31",
+ version: 31,
+ metadata: { id: "project-v31", name: "My project" },
+ scenes: [
+ {
+ id: "scene-1",
+ tracks: {
+ main: {
+ id: "track-1",
+ type: "video",
+ elements: [{ id: "elem-1", type: "video", mediaId: "media-1" }],
+ },
+ overlay: [],
+ audio: [],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(32);
+ const scene = asRecordArray(result.project.scenes)[0];
+ const main = asRecord(asRecord(scene.tracks).main);
+ const element = asRecordArray(main.elements)[0];
+ expect(element).toMatchObject({
+ id: "elem-1",
+ type: "video",
+ mediaId: "media-1",
+ });
+ expect(asRecord(result.project.metadata).name).toBe("My project");
+ });
+
+ test("skips a project that is already v32", () => {
+ const project = { id: "p1", version: 32, scenes: [] };
+ const result = transformProjectV31ToV32({ project });
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v32");
+ expect(result.project).toBe(project);
+ });
+
+ test("skips a project that is not v31", () => {
+ const project = { id: "p1", version: 30, scenes: [] };
+ const result = transformProjectV31ToV32({ project });
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("not v31");
+ expect(result.project).toBe(project);
+ });
+
+ test("skips a project without an id", () => {
+ const project = { version: 31, scenes: [] };
+ const result = transformProjectV31ToV32({ project });
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("no project id");
+ expect(result.project).toBe(project);
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index cbc35bb3..f3f2a22c 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -30,10 +30,11 @@ import { V27toV28Migration } from "./v27-to-v28";
import { V28toV29Migration } from "./v28-to-v29";
import { V29toV30Migration } from "./v29-to-v30";
import { V30toV31Migration } from "./v30-to-v31";
+import { V31toV32Migration } from "./v31-to-v32";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 31;
+export const CURRENT_PROJECT_VERSION = 32;
export const migrations = [
new V0toV1Migration(),
@@ -67,4 +68,5 @@ export const migrations = [
new V28toV29Migration(),
new V29toV30Migration(),
new V30toV31Migration(),
+ new V31toV32Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/v31-to-v32.ts b/apps/web/src/services/storage/migrations/transformers/v31-to-v32.ts
new file mode 100644
index 00000000..bedb4784
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v31-to-v32.ts
@@ -0,0 +1,31 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId } from "./utils";
+
+/**
+ * v31 → v32 introduces the `projectVersions` store (named checkpoints +
+ * auto-checkpoints; see notes/project-versioning.md). The store lives in its
+ * own IndexedDB database created lazily by the versions adapter, so project
+ * records need no shape change — this migration only stamps the new version.
+ */
+export function transformProjectV31ToV32({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 32) {
+ return { project, skipped: true, reason: "already v32" };
+ }
+ if (version !== 31) {
+ return { project, skipped: true, reason: "not v31" };
+ }
+
+ return { project: { ...project, version: 32 }, skipped: false };
+}
diff --git a/apps/web/src/services/storage/migrations/v31-to-v32.ts b/apps/web/src/services/storage/migrations/v31-to-v32.ts
new file mode 100644
index 00000000..0b6da324
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v31-to-v32.ts
@@ -0,0 +1,14 @@
+import { StorageMigration, type StorageMigrationRunArgs } from "./base";
+import type { MigrationResult, ProjectRecord } from "./transformers/types";
+import { transformProjectV31ToV32 } from "./transformers/v31-to-v32";
+
+export class V31toV32Migration extends StorageMigration {
+ from = 31;
+ to = 32;
+
+ async run({
+ project,
+ }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
+ return transformProjectV31ToV32({ project });
+ }
+}
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 };
}
diff --git a/apps/web/src/versions/__tests__/media-pin.test.ts b/apps/web/src/versions/__tests__/media-pin.test.ts
new file mode 100644
index 00000000..17fae4b2
--- /dev/null
+++ b/apps/web/src/versions/__tests__/media-pin.test.ts
@@ -0,0 +1,137 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import { EditorCore } from "@/core";
+import { buildDefaultScene } from "@/timeline/scenes";
+import type { ImageElement } from "@/timeline";
+import type { MediaAsset } from "@/media/types";
+import { mediaTimeFromSeconds, ZERO_MEDIA_TIME } from "@/wasm";
+import { CURRENT_PROJECT_VERSION } from "@/services/storage/migrations";
+import { InMemoryVersionStore } from "@/versions";
+
+const PROJECT_ID = "project-1";
+const IMAGE_DURATION = mediaTimeFromSeconds({ seconds: 5 });
+
+function buildImageElement({ mediaId }: { mediaId: string }): ImageElement {
+ return {
+ id: `image-${mediaId}`,
+ type: "image",
+ name: `image-${mediaId}`,
+ startTime: ZERO_MEDIA_TIME,
+ duration: IMAGE_DURATION,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ mediaId,
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
+ };
+}
+
+function buildImageAsset({ id }: { id: string }): MediaAsset {
+ return {
+ id,
+ name: id,
+ type: "image",
+ file: new File([new Uint8Array([1, 2, 3])], `${id}.png`, {
+ type: "image/png",
+ }),
+ };
+}
+
+function seedEditor(): { editor: EditorCore } {
+ const editor = EditorCore.getInstance();
+ editor.versions.setStore({ store: new InMemoryVersionStore() });
+ editor.versions.setNow({ now: () => 1_000_000_000 });
+
+ const scene = buildDefaultScene({ name: "Main scene", isMain: true });
+ // Only media-pinned is referenced by the document (and thus snapshots);
+ // media-free exists as an asset but is used nowhere.
+ scene.tracks.main.elements = [buildImageElement({ mediaId: "media-pinned" })];
+ editor.project.setActiveProject({
+ project: {
+ metadata: {
+ id: PROJECT_ID,
+ name: "Test project",
+ duration: IMAGE_DURATION,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
+ },
+ scenes: [scene],
+ currentSceneId: scene.id,
+ settings: {
+ fps: { numerator: 30, denominator: 1 },
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ version: CURRENT_PROJECT_VERSION,
+ },
+ });
+ editor.scenes.setScenes({ scenes: [scene], activeSceneId: scene.id });
+ editor.media.setAssets({
+ assets: [
+ buildImageAsset({ id: "media-pinned" }),
+ buildImageAsset({ id: "media-free" }),
+ ],
+ });
+ return { editor };
+}
+
+describe("Media pinning", () => {
+ beforeEach(() => {
+ EditorCore.reset();
+ });
+
+ test("deleting a media asset referenced by a stored version is blocked", async () => {
+ const { editor } = seedEditor();
+
+ const version = await editor.versions.createNamedCheckpoint({
+ name: "v1",
+ });
+ expect(version).not.toBeNull();
+
+ editor.media.removeMediaAsset({ projectId: PROJECT_ID, id: "media-pinned" });
+ expect(
+ editor.media.getAssets().some((asset) => asset.id === "media-pinned"),
+ ).toBe(true);
+
+ // An unreferenced asset in the same project deletes normally.
+ editor.media.removeMediaAsset({ projectId: PROJECT_ID, id: "media-free" });
+ expect(
+ editor.media.getAssets().some((asset) => asset.id === "media-free"),
+ ).toBe(false);
+ });
+
+ test("an asset is unpinned once the referencing version is deleted", async () => {
+ const { editor } = seedEditor();
+
+ const version = await editor.versions.createNamedCheckpoint({
+ name: "v1",
+ });
+ expect(version).not.toBeNull();
+ if (!version) return;
+
+ expect(
+ editor.versions.isMediaIdPinned({
+ projectId: PROJECT_ID,
+ mediaId: "media-pinned",
+ }),
+ ).toBe(true);
+
+ await editor.versions.deleteVersion({ versionId: version.id });
+ expect(
+ editor.versions.isMediaIdPinned({
+ projectId: PROJECT_ID,
+ mediaId: "media-pinned",
+ }),
+ ).toBe(false);
+
+ editor.media.removeMediaAsset({ projectId: PROJECT_ID, id: "media-pinned" });
+ expect(
+ editor.media.getAssets().some((asset) => asset.id === "media-pinned"),
+ ).toBe(false);
+ });
+});
diff --git a/apps/web/src/versions/__tests__/versions-manager.test.ts b/apps/web/src/versions/__tests__/versions-manager.test.ts
new file mode 100644
index 00000000..1322c71a
--- /dev/null
+++ b/apps/web/src/versions/__tests__/versions-manager.test.ts
@@ -0,0 +1,242 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import { EditorCore } from "@/core";
+import { buildDefaultScene } from "@/timeline/scenes";
+import type { VideoElement } from "@/timeline";
+import { mediaTimeFromSeconds, ZERO_MEDIA_TIME } from "@/wasm";
+import { serializeProject } from "@/services/storage/service";
+import { CURRENT_PROJECT_VERSION } from "@/services/storage/migrations";
+import {
+ AUTO_CHECKPOINT_MIN_INTERVAL_MS,
+ AUTO_CHECKPOINT_RETENTION,
+ InMemoryVersionStore,
+} from "@/versions";
+
+const PROJECT_ID = "project-1";
+const VIDEO_DURATION = mediaTimeFromSeconds({ seconds: 10 });
+const SPLIT_TIME = mediaTimeFromSeconds({ seconds: 4 });
+
+let nowMs = 1_000_000_000;
+
+function buildVideoElement(): VideoElement {
+ return {
+ id: "video-1",
+ type: "video",
+ name: "video-1",
+ startTime: ZERO_MEDIA_TIME,
+ duration: VIDEO_DURATION,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ mediaId: "media-video-1",
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
+ };
+}
+
+function seedEditor(): { editor: EditorCore; store: InMemoryVersionStore } {
+ const editor = EditorCore.getInstance();
+ const store = new InMemoryVersionStore();
+ editor.versions.setStore({ store });
+ editor.versions.setNow({ now: () => nowMs });
+
+ const scene = buildDefaultScene({ name: "Main scene", isMain: true });
+ scene.tracks.main.elements = [buildVideoElement()];
+ editor.project.setActiveProject({
+ project: {
+ metadata: {
+ id: PROJECT_ID,
+ name: "Test project",
+ duration: VIDEO_DURATION,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
+ },
+ scenes: [scene],
+ currentSceneId: scene.id,
+ settings: {
+ fps: { numerator: 30, denominator: 1 },
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ version: CURRENT_PROJECT_VERSION,
+ },
+ });
+ editor.scenes.setScenes({ scenes: [scene], activeSceneId: scene.id });
+ return { editor, store };
+}
+
+function splitSeededVideo({ editor }: { editor: EditorCore }): void {
+ const scene = editor.scenes.getActiveScene();
+ editor.timeline.splitElements({
+ elements: [{ trackId: scene.tracks.main.id, elementId: "video-1" }],
+ splitTime: SPLIT_TIME,
+ });
+}
+
+describe("VersionsManager", () => {
+ beforeEach(() => {
+ EditorCore.reset();
+ });
+
+ test("restore round-trips the document through a named checkpoint", async () => {
+ const { editor } = seedEditor();
+
+ const version = await editor.versions.createNamedCheckpoint({
+ name: "v1",
+ });
+ expect(version).not.toBeNull();
+ if (!version) return;
+
+ splitSeededVideo({ editor });
+ expect(
+ editor.scenes.getActiveScene().tracks.main.elements,
+ ).toHaveLength(2);
+
+ const didRestore = await editor.versions.restoreVersion({
+ versionId: version.id,
+ });
+ expect(didRestore).toBe(true);
+
+ const restored = editor.project.getActive();
+ expect(serializeProject({ project: restored })).toEqual(version.project);
+ });
+
+ test("restore writes a safety auto-checkpoint of the abandoned head", async () => {
+ const { editor, store } = seedEditor();
+
+ const version = await editor.versions.createNamedCheckpoint({
+ name: "v1",
+ });
+ expect(version).not.toBeNull();
+ if (!version) return;
+
+ nowMs += AUTO_CHECKPOINT_MIN_INTERVAL_MS;
+ splitSeededVideo({ editor });
+
+ const didRestore = await editor.versions.restoreVersion({
+ versionId: version.id,
+ });
+ expect(didRestore).toBe(true);
+
+ const stored = await store.getAllForProject({ projectId: PROJECT_ID });
+ const autos = stored.filter((candidate) => candidate.kind === "auto");
+ expect(autos).toHaveLength(1);
+
+ // The safety checkpoint captures the mutated (split) document…
+ const safetyScene = autos[0].project.scenes[0];
+ expect(safetyScene.tracks.main.elements).toHaveLength(2);
+ // …while the live document is back at the checkpointed state.
+ expect(
+ editor.scenes.getActiveScene().tracks.main.elements,
+ ).toHaveLength(1);
+ });
+
+ test("auto-checkpoints rotate as a ring buffer and never evict named versions", async () => {
+ const { editor, store } = seedEditor();
+
+ nowMs += AUTO_CHECKPOINT_MIN_INTERVAL_MS;
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+ const [firstAuto] = await store.getAllForProject({
+ projectId: PROJECT_ID,
+ });
+
+ for (let i = 0; i < AUTO_CHECKPOINT_RETENTION; i++) {
+ nowMs += AUTO_CHECKPOINT_MIN_INTERVAL_MS;
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+ }
+
+ let stored = await store.getAllForProject({ projectId: PROJECT_ID });
+ let autos = stored.filter((candidate) => candidate.kind === "auto");
+ expect(autos).toHaveLength(AUTO_CHECKPOINT_RETENTION);
+ // The 21st write evicted the oldest auto-checkpoint.
+ expect(autos.some((candidate) => candidate.id === firstAuto.id)).toBe(
+ false,
+ );
+
+ const named = await editor.versions.createNamedCheckpoint({
+ name: "keep-me",
+ });
+ expect(named).not.toBeNull();
+
+ nowMs += AUTO_CHECKPOINT_MIN_INTERVAL_MS;
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+
+ stored = await store.getAllForProject({ projectId: PROJECT_ID });
+ autos = stored.filter((candidate) => candidate.kind === "auto");
+ expect(autos).toHaveLength(AUTO_CHECKPOINT_RETENTION);
+ expect(
+ stored.some(
+ (candidate) => candidate.kind === "named" && candidate.name === "keep-me",
+ ),
+ ).toBe(true);
+ });
+
+ test("auto-checkpoints are throttled to one per interval", async () => {
+ const { editor, store } = seedEditor();
+
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(false);
+
+ nowMs += AUTO_CHECKPOINT_MIN_INTERVAL_MS - 1;
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(false);
+
+ nowMs += 1;
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+
+ const stored = await store.getAllForProject({ projectId: PROJECT_ID });
+ const autos = stored.filter((candidate) => candidate.kind === "auto");
+ expect(autos).toHaveLength(2);
+ });
+
+ test("auto-checkpoints cannot be renamed or deleted", async () => {
+ const { editor } = seedEditor();
+
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+ const [auto] = editor.versions.getVersions({ projectId: PROJECT_ID });
+ expect(auto.kind).toBe("auto");
+
+ expect(
+ await editor.versions.renameVersion({
+ versionId: auto.id,
+ name: "nope",
+ }),
+ ).toBe(false);
+ expect(await editor.versions.deleteVersion({ versionId: auto.id })).toBe(
+ false,
+ );
+ expect(
+ editor.versions.getVersions({ projectId: PROJECT_ID }),
+ ).toHaveLength(1);
+ });
+
+ test("named checkpoints can be renamed and deleted", async () => {
+ const { editor } = seedEditor();
+
+ const version = await editor.versions.createNamedCheckpoint({
+ name: "v1",
+ });
+ expect(version).not.toBeNull();
+ if (!version) return;
+
+ expect(
+ await editor.versions.renameVersion({
+ versionId: version.id,
+ name: "v1-final",
+ }),
+ ).toBe(true);
+ expect(
+ editor.versions.getVersions({ projectId: PROJECT_ID })[0].name,
+ ).toBe("v1-final");
+
+ expect(
+ await editor.versions.deleteVersion({ versionId: version.id }),
+ ).toBe(true);
+ expect(
+ editor.versions.getVersions({ projectId: PROJECT_ID }),
+ ).toHaveLength(0);
+ });
+});
diff --git a/apps/web/src/versions/components/version-name-dialog.tsx b/apps/web/src/versions/components/version-name-dialog.tsx
new file mode 100644
index 00000000..f0514f5a
--- /dev/null
+++ b/apps/web/src/versions/components/version-name-dialog.tsx
@@ -0,0 +1,77 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+import { Label } from "@/components/ui/label";
+
+export function VersionNameDialog({
+ isOpen,
+ onOpenChange,
+ onConfirm,
+ mode,
+ initialName = "",
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: (name: string) => void;
+ mode: "create" | "rename";
+ initialName?: string;
+}) {
+ const [name, setName] = useState(initialName);
+
+ const handleOpenChange = (open: boolean) => {
+ if (open) {
+ setName(initialName);
+ }
+ onOpenChange(open);
+ };
+
+ const title = mode === "create" ? "Create checkpoint" : "Rename checkpoint";
+ const confirmLabel = mode === "create" ? "Create" : "Rename";
+
+ return (
+ <Dialog open={isOpen} onOpenChange={handleOpenChange}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ </DialogHeader>
+
+ <DialogBody className="gap-3">
+ <Label>Name</Label>
+ <Input
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onConfirm(name);
+ }
+ }}
+ placeholder="e.g. Final cut"
+ />
+ </DialogBody>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button onClick={() => onConfirm(name)}>{confirmLabel}</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/versions/components/versions-popover.tsx b/apps/web/src/versions/components/versions-popover.tsx
new file mode 100644
index 00000000..b97e62da
--- /dev/null
+++ b/apps/web/src/versions/components/versions-popover.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+import { useState } from "react";
+import Image from "next/image";
+import {
+ Clock01Icon,
+ Delete02Icon,
+ Edit02Icon,
+ PlusSignIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useEditor } from "@/editor/use-editor";
+import { useVersionsStore } from "@/versions/versions-store";
+import { VersionNameDialog } from "@/versions/components/version-name-dialog";
+import type { ProjectVersion } from "@/versions/types";
+
+function formatVersionDate({ date }: { date: Date }): string {
+ return new Date(date).toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+}
+
+function getVersionTitle({ version }: { version: ProjectVersion }): string {
+ return version.kind === "named" && version.name
+ ? version.name
+ : "Auto-checkpoint";
+}
+
+export function VersionsButton() {
+ const editor = useEditor();
+ const isPanelOpen = useVersionsStore((s) => s.isPanelOpen);
+ const setPanelOpen = useVersionsStore((s) => s.setPanelOpen);
+ const isCheckpointDialogOpen = useVersionsStore(
+ (s) => s.isCheckpointDialogOpen,
+ );
+ const closeCheckpointDialog = useVersionsStore(
+ (s) => s.closeCheckpointDialog,
+ );
+ const openCheckpointDialog = useVersionsStore((s) => s.openCheckpointDialog);
+ const projectId = useEditor((e) => e.project.getActiveOrNull()?.metadata.id);
+ const versions = useEditor((e) =>
+ projectId ? e.versions.getVersions({ projectId }) : [],
+ );
+
+ const [restoreTarget, setRestoreTarget] = useState<ProjectVersion | null>(
+ null,
+ );
+ const [renameTarget, setRenameTarget] = useState<ProjectVersion | null>(null);
+ const [deleteTarget, setDeleteTarget] = useState<ProjectVersion | null>(null);
+
+ const handlePanelOpenChange = (open: boolean) => {
+ setPanelOpen({ open });
+ if (open && projectId) {
+ editor.versions.listVersions({ projectId }).catch((error) => {
+ console.error("Failed to refresh versions:", error);
+ });
+ }
+ };
+
+ const handleCreateCheckpoint = (name: string) => {
+ closeCheckpointDialog();
+ editor.versions.createNamedCheckpoint({ name }).catch((error) => {
+ console.error("Failed to create checkpoint:", error);
+ });
+ };
+
+ return (
+ <>
+ <Popover open={isPanelOpen} onOpenChange={handlePanelOpenChange}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-8"
+ title="Versions"
+ >
+ <HugeiconsIcon icon={Clock01Icon} className="size-4" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent align="end" className="w-80 p-2">
+ <div className="flex items-center justify-between px-2 pb-2">
+ <span className="text-sm font-medium">Versions</span>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openCheckpointDialog}
+ >
+ <HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
+ Checkpoint
+ </Button>
+ </div>
+ {versions.length === 0 ? (
+ <p className="text-muted-foreground px-2 py-6 text-center text-sm">
+ No versions yet. Create a checkpoint to save a known-good state.
+ </p>
+ ) : (
+ <ScrollArea className="max-h-80">
+ <div className="flex flex-col gap-1">
+ {versions.map((version) => (
+ <VersionRow
+ key={version.id}
+ version={version}
+ onRestore={() => setRestoreTarget(version)}
+ onRename={
+ version.kind === "named"
+ ? () => setRenameTarget(version)
+ : undefined
+ }
+ onDelete={
+ version.kind === "named"
+ ? () => setDeleteTarget(version)
+ : undefined
+ }
+ />
+ ))}
+ </div>
+ </ScrollArea>
+ )}
+ </PopoverContent>
+ </Popover>
+
+ <VersionNameDialog
+ isOpen={isCheckpointDialogOpen}
+ onOpenChange={(open) => {
+ if (!open) closeCheckpointDialog();
+ }}
+ onConfirm={handleCreateCheckpoint}
+ mode="create"
+ />
+
+ <VersionNameDialog
+ isOpen={renameTarget !== null}
+ onOpenChange={(open) => {
+ if (!open) setRenameTarget(null);
+ }}
+ onConfirm={(name) => {
+ const target = renameTarget;
+ setRenameTarget(null);
+ if (target) {
+ editor.versions
+ .renameVersion({ versionId: target.id, name })
+ .catch((error) => {
+ console.error("Failed to rename checkpoint:", error);
+ });
+ }
+ }}
+ mode="rename"
+ initialName={renameTarget?.name ?? ""}
+ />
+
+ <AlertDialog
+ open={restoreTarget !== null}
+ onOpenChange={(open) => {
+ if (!open) setRestoreTarget(null);
+ }}
+ >
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>
+ Restore {restoreTarget ? getVersionTitle({ version: restoreTarget }) : ""}?
+ </AlertDialogTitle>
+ <AlertDialogDescription>
+ The editor will jump to this version. Your current state is saved
+ as an auto-checkpoint first, so nothing is lost.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={() => {
+ const target = restoreTarget;
+ setRestoreTarget(null);
+ if (target) {
+ editor.versions
+ .restoreVersion({ versionId: target.id })
+ .catch((error) => {
+ console.error("Failed to restore version:", error);
+ });
+ }
+ }}
+ >
+ Restore
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+
+ <AlertDialog
+ open={deleteTarget !== null}
+ onOpenChange={(open) => {
+ if (!open) setDeleteTarget(null);
+ }}
+ >
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>
+ Delete {deleteTarget ? getVersionTitle({ version: deleteTarget }) : ""}?
+ </AlertDialogTitle>
+ <AlertDialogDescription>
+ This checkpoint is removed permanently. The current state of the
+ project is not affected.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={() => {
+ const target = deleteTarget;
+ setDeleteTarget(null);
+ if (target) {
+ editor.versions
+ .deleteVersion({ versionId: target.id })
+ .catch((error) => {
+ console.error("Failed to delete checkpoint:", error);
+ });
+ }
+ }}
+ >
+ Delete
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ );
+}
+
+function VersionRow({
+ version,
+ onRestore,
+ onRename,
+ onDelete,
+}: {
+ version: ProjectVersion;
+ onRestore: () => void;
+ onRename?: () => void;
+ onDelete?: () => void;
+}) {
+ return (
+ <div className="hover:bg-accent flex items-center gap-1 rounded-sm p-1">
+ <Button
+ variant="ghost"
+ className="h-auto flex-1 justify-start gap-2 px-1 py-1 text-left font-normal"
+ onClick={onRestore}
+ >
+ <div className="bg-muted relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-sm">
+ {version.thumbnail ? (
+ <Image
+ src={version.thumbnail}
+ alt=""
+ fill
+ sizes="48px"
+ className="object-cover"
+ />
+ ) : (
+ <HugeiconsIcon
+ icon={Clock01Icon}
+ className="text-muted-foreground size-4"
+ />
+ )}
+ </div>
+ <div className="flex min-w-0 flex-col gap-0.5">
+ <span className="truncate text-sm">
+ {getVersionTitle({ version })}
+ </span>
+ <span className="text-muted-foreground text-xs">
+ {formatVersionDate({ date: version.createdAt })}
+ </span>
+ </div>
+ <Badge
+ variant={version.kind === "named" ? "default" : "secondary"}
+ className="ml-auto shrink-0"
+ >
+ {version.kind === "named" ? "Named" : "Auto"}
+ </Badge>
+ </Button>
+ {onRename && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-7 shrink-0"
+ title="Rename checkpoint"
+ onClick={onRename}
+ >
+ <HugeiconsIcon icon={Edit02Icon} className="size-3.5" />
+ </Button>
+ )}
+ {onDelete && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-7 shrink-0"
+ title="Delete checkpoint"
+ onClick={onDelete}
+ >
+ <HugeiconsIcon icon={Delete02Icon} className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/versions/index.ts b/apps/web/src/versions/index.ts
new file mode 100644
index 00000000..e9f69841
--- /dev/null
+++ b/apps/web/src/versions/index.ts
@@ -0,0 +1,13 @@
+export type { ProjectVersion, ProjectVersionKind } from "./types";
+export type { VersionStore } from "./store";
+export {
+ InMemoryVersionStore,
+ IndexedDBVersionStore,
+} from "./store";
+export { getReferencedMediaIds } from "./media-references";
+export {
+ AUTO_CHECKPOINT_MIN_INTERVAL_MS,
+ AUTO_CHECKPOINT_RETENTION,
+ VersionsManager,
+} from "./versions-manager";
+export { useVersionsStore } from "./versions-store";
diff --git a/apps/web/src/versions/media-references.ts b/apps/web/src/versions/media-references.ts
new file mode 100644
index 00000000..86264320
--- /dev/null
+++ b/apps/web/src/versions/media-references.ts
@@ -0,0 +1,52 @@
+import type { SerializedProject } from "@/services/storage/types";
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function collectFromElements({
+ elements,
+ mediaIds,
+}: {
+ elements: unknown;
+ mediaIds: Set<string>;
+}): void {
+ if (!Array.isArray(elements)) return;
+
+ for (const element of elements) {
+ if (!isRecord(element)) continue;
+ if (typeof element.mediaId === "string" && element.mediaId.length > 0) {
+ mediaIds.add(element.mediaId);
+ }
+ }
+}
+
+/**
+ * Extracts every media asset id referenced by a serialized project's scenes.
+ * Duck-typed (like migrations) so snapshots of any project version scan safely.
+ */
+export function getReferencedMediaIds({
+ project,
+}: {
+ project: SerializedProject;
+}): Set<string> {
+ const mediaIds = new Set<string>();
+
+ for (const scene of project.scenes ?? []) {
+ const tracks: unknown = scene.tracks;
+ if (!isRecord(tracks)) continue;
+
+ if (isRecord(tracks.main)) {
+ collectFromElements({ elements: tracks.main.elements, mediaIds });
+ }
+ for (const list of [tracks.overlay, tracks.audio]) {
+ if (!Array.isArray(list)) continue;
+ for (const track of list) {
+ if (!isRecord(track)) continue;
+ collectFromElements({ elements: track.elements, mediaIds });
+ }
+ }
+ }
+
+ return mediaIds;
+}
diff --git a/apps/web/src/versions/store.ts b/apps/web/src/versions/store.ts
new file mode 100644
index 00000000..ae3f5d11
--- /dev/null
+++ b/apps/web/src/versions/store.ts
@@ -0,0 +1,86 @@
+import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
+import type { ProjectVersion } from "./types";
+
+/**
+ * Persistence backend for project versions. The IndexedDB implementation is
+ * the production store (its own database, created lazily — the v31→v32
+ * migration stamps project records); the in-memory implementation backs
+ * unit tests.
+ */
+export interface VersionStore {
+ put({ version }: { version: ProjectVersion }): Promise<void>;
+ getAllForProject({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<ProjectVersion[]>;
+ delete({ id }: { id: string }): Promise<void>;
+ deleteForProject({ projectId }: { projectId: string }): Promise<void>;
+}
+
+export class IndexedDBVersionStore implements VersionStore {
+ private adapter = new IndexedDBAdapter<ProjectVersion>({
+ dbName: "video-editor-project-versions",
+ storeName: "project-versions",
+ version: 1,
+ });
+
+ async put({ version }: { version: ProjectVersion }): Promise<void> {
+ await this.adapter.set({ key: version.id, value: version });
+ }
+
+ async getAllForProject({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<ProjectVersion[]> {
+ const all = await this.adapter.getAll();
+ return all.filter(
+ (version) =>
+ typeof version === "object" &&
+ version !== null &&
+ version.projectId === projectId,
+ );
+ }
+
+ async delete({ id }: { id: string }): Promise<void> {
+ await this.adapter.remove(id);
+ }
+
+ async deleteForProject({ projectId }: { projectId: string }): Promise<void> {
+ const versions = await this.getAllForProject({ projectId });
+ await Promise.all(
+ versions.map((version) => this.adapter.remove(version.id)),
+ );
+ }
+}
+
+export class InMemoryVersionStore implements VersionStore {
+ private versions = new Map<string, ProjectVersion>();
+
+ async put({ version }: { version: ProjectVersion }): Promise<void> {
+ this.versions.set(version.id, version);
+ }
+
+ async getAllForProject({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<ProjectVersion[]> {
+ return [...this.versions.values()].filter(
+ (version) => version.projectId === projectId,
+ );
+ }
+
+ async delete({ id }: { id: string }): Promise<void> {
+ this.versions.delete(id);
+ }
+
+ async deleteForProject({ projectId }: { projectId: string }): Promise<void> {
+ for (const version of this.versions.values()) {
+ if (version.projectId === projectId) {
+ this.versions.delete(version.id);
+ }
+ }
+ }
+}
diff --git a/apps/web/src/versions/types.ts b/apps/web/src/versions/types.ts
new file mode 100644
index 00000000..d2852e8d
--- /dev/null
+++ b/apps/web/src/versions/types.ts
@@ -0,0 +1,21 @@
+import type { SerializedProject } from "@/services/storage/types";
+
+export type ProjectVersionKind = "named" | "auto";
+
+/**
+ * A point-in-time snapshot of a project (see notes/project-versioning.md).
+ *
+ * Media is referenced by asset id inside `project` (v1 keeps the per-project
+ * media store); use `getReferencedMediaIds` to extract the referenced set.
+ */
+export interface ProjectVersion {
+ id: string;
+ projectId: string;
+ kind: ProjectVersionKind;
+ /** Required for kind "named". */
+ name?: string;
+ createdAt: Date;
+ project: SerializedProject;
+ /** Data URL captured via renderer.captureFrame(); named versions only. */
+ thumbnail?: string;
+}
diff --git a/apps/web/src/versions/versions-manager.ts b/apps/web/src/versions/versions-manager.ts
new file mode 100644
index 00000000..6dba0c8c
--- /dev/null
+++ b/apps/web/src/versions/versions-manager.ts
@@ -0,0 +1,503 @@
+import type { EditorCore } from "@/core";
+import { toast } from "sonner";
+import { generateUUID } from "@/utils/id";
+import type { TProject } from "@/project/types";
+import {
+ deserializeProject,
+ serializeProject,
+} from "@/services/storage/service";
+import { isStorageQuotaExceededError } from "@/services/storage/quota";
+import { getProjectDurationFromScenes } from "@/timeline/scenes";
+import { ZERO_MEDIA_TIME } from "@/wasm";
+import { getReferencedMediaIds } from "./media-references";
+import {
+ IndexedDBVersionStore,
+ InMemoryVersionStore,
+ type VersionStore,
+} from "./store";
+import type { ProjectVersion } from "./types";
+
+export const AUTO_CHECKPOINT_RETENTION = 20;
+export const AUTO_CHECKPOINT_MIN_INTERVAL_MS = 60_000;
+
+const VERSION_THUMBNAIL_MAX_WIDTH = 320;
+
+async function captureVersionThumbnail({
+ editor,
+}: {
+ editor: EditorCore;
+}): Promise<string | undefined> {
+ try {
+ const blob = await editor.renderer.captureFrame();
+ if (!blob) return undefined;
+
+ const bitmap = await createImageBitmap(blob);
+ const scale = Math.min(1, VERSION_THUMBNAIL_MAX_WIDTH / bitmap.width);
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
+ const context = canvas.getContext("2d");
+ if (!context) {
+ bitmap.close();
+ return undefined;
+ }
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
+ bitmap.close();
+ return canvas.toDataURL("image/jpeg", 0.75);
+ } catch (error) {
+ console.error("Failed to capture version thumbnail:", error);
+ return undefined;
+ }
+}
+
+/**
+ * Named checkpoints + throttled auto-checkpoints (ring buffer) per project.
+ * Restore is a wholesale document swap that deliberately bypasses undo/redo:
+ * undoing a restore = the safety auto-checkpoint written before the jump.
+ * See notes/project-versioning.md.
+ */
+export class VersionsManager {
+ private versionsByProject = new Map<string, ProjectVersion[]>();
+ private pinnedMediaByProject = new Map<string, Set<string>>();
+ private lastAutoCheckpointAt = new Map<string, number>();
+ private hydratedProjectId: string | null = null;
+ private listeners = new Set<() => void>();
+ private store: VersionStore;
+ private now: () => number;
+
+ constructor({
+ editor,
+ store,
+ now,
+ }: {
+ editor: EditorCore;
+ store?: VersionStore;
+ now?: () => number;
+ }) {
+ this.editor = editor;
+ // Without IndexedDB (unit tests, unsupported browsers) persistence is
+ // impossible; fall back to the in-memory store instead of failing.
+ this.store =
+ store ??
+ (typeof indexedDB === "undefined"
+ ? new InMemoryVersionStore()
+ : new IndexedDBVersionStore());
+ this.now = now ?? (() => Date.now());
+
+ this.editor.project.subscribe(() => {
+ const activeProjectId =
+ this.editor.project.getActiveOrNull()?.metadata.id ?? null;
+ if (activeProjectId === this.hydratedProjectId) return;
+ this.hydratedProjectId = activeProjectId;
+ if (activeProjectId) {
+ this.listVersions({ projectId: activeProjectId }).catch((error) => {
+ console.error("Failed to load project versions:", error);
+ });
+ }
+ });
+ }
+
+ private editor: EditorCore;
+
+ /** Test seam: swap the persistence backend / clock. */
+ setStore({ store }: { store: VersionStore }): void {
+ this.store = store;
+ }
+
+ setNow({ now }: { now: () => number }): void {
+ this.now = now;
+ }
+
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ private notify(): void {
+ this.listeners.forEach((listener) => {
+ listener();
+ });
+ }
+
+ /** Cached versions for a project, newest first. */
+ getVersions({ projectId }: { projectId: string }): ProjectVersion[] {
+ return this.versionsByProject.get(projectId) ?? [];
+ }
+
+ /** Reloads versions for a project from the store into the cache. */
+ async listVersions({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<ProjectVersion[]> {
+ try {
+ const versions = sortNewestFirst({
+ versions: await this.store.getAllForProject({ projectId }),
+ });
+ this.setProjectVersions({ projectId, versions });
+ return versions;
+ } catch (error) {
+ console.error("Failed to list project versions:", error);
+ return this.getVersions({ projectId });
+ }
+ }
+
+ async createNamedCheckpoint({
+ name,
+ }: {
+ name: string;
+ }): Promise<ProjectVersion | null> {
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ toast.error("Checkpoint needs a name");
+ return null;
+ }
+
+ const thumbnail = await captureVersionThumbnail({ editor: this.editor });
+ const version = this.buildSnapshot({
+ kind: "named",
+ name: trimmedName,
+ thumbnail,
+ });
+ if (!version) return null;
+
+ const didWrite = await this.putVersion({ version });
+ if (!didWrite) return null;
+
+ toast.success(`Checkpoint "${trimmedName}" created`);
+ return version;
+ }
+
+ /**
+ * Writes an auto-checkpoint of the current head, throttled to at most one
+ * per AUTO_CHECKPOINT_MIN_INTERVAL_MS and project. `force` skips the
+ * throttle (used for the safety checkpoint before a restore jump).
+ * Returns false when throttled, when there is nothing to snapshot, or when
+ * the write failed.
+ */
+ async writeAutoCheckpoint({
+ force = false,
+ }: { force?: boolean } = {}): Promise<boolean> {
+ const activeProject = this.editor.project.getActiveOrNull();
+ if (!activeProject) return false;
+
+ const projectId = activeProject.metadata.id;
+ const nowMs = this.now();
+ const lastAt = this.lastAutoCheckpointAt.get(projectId);
+ if (
+ !force &&
+ lastAt !== undefined &&
+ nowMs - lastAt < AUTO_CHECKPOINT_MIN_INTERVAL_MS
+ ) {
+ return false;
+ }
+
+ const version = this.buildSnapshot({ kind: "auto" });
+ if (!version) return false;
+
+ const didWrite = await this.putVersion({ version });
+ if (!didWrite) return false;
+
+ this.lastAutoCheckpointAt.set(projectId, nowMs);
+ await this.rotateAutoCheckpoints({ projectId });
+ return true;
+ }
+
+ /**
+ * Jumps head to a stored version: writes a safety auto-checkpoint of the
+ * current head first, then swaps the document wholesale (bypassing
+ * undo/redo — undo of a restore = that safety checkpoint).
+ */
+ async restoreVersion({ versionId }: { versionId: string }): Promise<boolean> {
+ const activeProject = this.editor.project.getActiveOrNull();
+ if (!activeProject) return false;
+
+ const version = this.getVersions({
+ projectId: activeProject.metadata.id,
+ }).find((candidate) => candidate.id === versionId);
+ if (!version) {
+ toast.error("Version not found");
+ return false;
+ }
+
+ const didWriteSafetyCheckpoint = await this.writeAutoCheckpoint({
+ force: true,
+ });
+ if (!didWriteSafetyCheckpoint) {
+ toast.error("Couldn't save a safety checkpoint of the current state", {
+ description: "Restore aborted so no work is lost.",
+ });
+ return false;
+ }
+
+ const project = deserializeProject({ serialized: version.project });
+ this.editor.project.setActiveProject({ project });
+ this.editor.scenes.initializeScenes({
+ scenes: project.scenes,
+ currentSceneId: project.currentSceneId,
+ });
+ this.editor.playback.seek({ time: ZERO_MEDIA_TIME });
+ this.editor.command.clear();
+ this.editor.selection.clearSelection();
+ this.editor.save.markDirty({ force: true });
+ this.notify();
+
+ toast.success(`Restored ${getVersionLabel({ version })}`, {
+ description: "Previous state saved as an auto-checkpoint.",
+ });
+ return true;
+ }
+
+ async renameVersion({
+ versionId,
+ name,
+ }: {
+ versionId: string;
+ name: string;
+ }): Promise<boolean> {
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ toast.error("Checkpoint needs a name");
+ return false;
+ }
+
+ const found = this.findVersion({ versionId });
+ if (!found) {
+ toast.error("Version not found");
+ return false;
+ }
+ if (found.version.kind !== "named") {
+ toast.error("Auto-checkpoints can't be renamed");
+ return false;
+ }
+
+ const renamed: ProjectVersion = { ...found.version, name: trimmedName };
+ try {
+ await this.store.put({ version: renamed });
+ } catch (error) {
+ this.toastStoreError({ error, fallback: "Failed to rename checkpoint" });
+ return false;
+ }
+
+ this.replaceCachedVersion({ version: renamed });
+ return true;
+ }
+
+ /** Deletes a named checkpoint. Auto-checkpoints only leave via rotation. */
+ async deleteVersion({ versionId }: { versionId: string }): Promise<boolean> {
+ const found = this.findVersion({ versionId });
+ if (!found) {
+ toast.error("Version not found");
+ return false;
+ }
+ if (found.version.kind !== "named") {
+ toast.error("Auto-checkpoints can't be deleted", {
+ description: "They rotate out automatically.",
+ });
+ return false;
+ }
+
+ try {
+ await this.store.delete({ id: versionId });
+ } catch (error) {
+ this.toastStoreError({ error, fallback: "Failed to delete checkpoint" });
+ return false;
+ }
+
+ const remaining = this.getVersions({ projectId: found.projectId }).filter(
+ (version) => version.id !== versionId,
+ );
+ this.setProjectVersions({ projectId: found.projectId, versions: remaining });
+ return true;
+ }
+
+ async deleteVersionsForProject({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<void> {
+ try {
+ await this.store.deleteForProject({ projectId });
+ } catch (error) {
+ console.error("Failed to delete project versions:", error);
+ }
+ this.versionsByProject.delete(projectId);
+ this.pinnedMediaByProject.delete(projectId);
+ this.lastAutoCheckpointAt.delete(projectId);
+ this.notify();
+ }
+
+ /**
+ * Sync pin check backed by the cached version list (hydrated on project
+ * load). Deleting a pinned media asset is blocked so a restore can never
+ * hit a missing blob.
+ */
+ isMediaIdPinned({
+ projectId,
+ mediaId,
+ }: {
+ projectId: string;
+ mediaId: string;
+ }): boolean {
+ return this.pinnedMediaByProject.get(projectId)?.has(mediaId) ?? false;
+ }
+
+ private buildSnapshot({
+ kind,
+ name,
+ thumbnail,
+ }: {
+ kind: ProjectVersion["kind"];
+ name?: string;
+ thumbnail?: string;
+ }): ProjectVersion | null {
+ const activeProject = this.editor.project.getActiveOrNull();
+ if (!activeProject) return null;
+
+ const scenes = this.editor.scenes.getScenes();
+ const project: TProject = {
+ ...activeProject,
+ scenes,
+ metadata: {
+ ...activeProject.metadata,
+ duration: getProjectDurationFromScenes({ scenes }),
+ updatedAt: new Date(this.now()),
+ },
+ };
+
+ return {
+ id: generateUUID(),
+ projectId: activeProject.metadata.id,
+ kind,
+ ...(name !== undefined && { name }),
+ createdAt: new Date(this.now()),
+ project: serializeProject({ project }),
+ ...(thumbnail !== undefined && { thumbnail }),
+ };
+ }
+
+ /** Persists a version and reflects it in the cache. Returns false on failure. */
+ private async putVersion({
+ version,
+ }: {
+ version: ProjectVersion;
+ }): Promise<boolean> {
+ try {
+ await this.store.put({ version });
+ } catch (error) {
+ this.toastStoreError({ error, fallback: "Failed to save checkpoint" });
+ return false;
+ }
+
+ const versions = [
+ version,
+ ...this.getVersions({ projectId: version.projectId }),
+ ];
+ this.setProjectVersions({
+ projectId: version.projectId,
+ versions: sortNewestFirst({ versions }),
+ });
+ return true;
+ }
+
+ /** Ring buffer: keep the newest AUTO_CHECKPOINT_RETENTION autos per project. */
+ private async rotateAutoCheckpoints({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<void> {
+ const versions = this.getVersions({ projectId });
+ const autoVersions = versions.filter((version) => version.kind === "auto");
+ const evicted = autoVersions.slice(AUTO_CHECKPOINT_RETENTION);
+ if (evicted.length === 0) return;
+
+ await Promise.all(
+ evicted.map(async (version) => {
+ try {
+ await this.store.delete({ id: version.id });
+ } catch (error) {
+ console.error("Failed to rotate auto-checkpoint:", error);
+ }
+ }),
+ );
+
+ const evictedIds = new Set(evicted.map((version) => version.id));
+ this.setProjectVersions({
+ projectId,
+ versions: versions.filter((version) => !evictedIds.has(version.id)),
+ });
+ }
+
+ private findVersion({
+ versionId,
+ }: {
+ versionId: string;
+ }): { projectId: string; version: ProjectVersion } | null {
+ for (const [projectId, versions] of this.versionsByProject) {
+ const version = versions.find((candidate) => candidate.id === versionId);
+ if (version) return { projectId, version };
+ }
+ return null;
+ }
+
+ private replaceCachedVersion({ version }: { version: ProjectVersion }): void {
+ const versions = this.getVersions({ projectId: version.projectId }).map(
+ (candidate) => (candidate.id === version.id ? version : candidate),
+ );
+ this.setProjectVersions({ projectId: version.projectId, versions });
+ }
+
+ private setProjectVersions({
+ projectId,
+ versions,
+ }: {
+ projectId: string;
+ versions: ProjectVersion[];
+ }): void {
+ this.versionsByProject.set(projectId, versions);
+ const pinned = new Set<string>();
+ for (const version of versions) {
+ for (const mediaId of getReferencedMediaIds({
+ project: version.project,
+ })) {
+ pinned.add(mediaId);
+ }
+ }
+ this.pinnedMediaByProject.set(projectId, pinned);
+ this.notify();
+ }
+
+ private toastStoreError({
+ error,
+ fallback,
+ }: {
+ error: unknown;
+ fallback: string;
+ }): void {
+ if (isStorageQuotaExceededError({ error })) {
+ toast.error("Not enough browser storage", {
+ description: error instanceof Error ? error.message : undefined,
+ });
+ return;
+ }
+ console.error(`${fallback}:`, error);
+ toast.error(fallback);
+ }
+}
+
+function sortNewestFirst({
+ versions,
+}: {
+ versions: ProjectVersion[];
+}): ProjectVersion[] {
+ return [...versions].sort(
+ (a, b) =>
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+ );
+}
+
+function getVersionLabel({ version }: { version: ProjectVersion }): string {
+ return version.kind === "named" && version.name
+ ? `"${version.name}"`
+ : "auto-checkpoint";
+}
diff --git a/apps/web/src/versions/versions-store.ts b/apps/web/src/versions/versions-store.ts
new file mode 100644
index 00000000..7ae957fb
--- /dev/null
+++ b/apps/web/src/versions/versions-store.ts
@@ -0,0 +1,23 @@
+import { create } from "zustand";
+
+/**
+ * UI-only state for the versions surfaces (popover + checkpoint dialog).
+ * Document state lives in EditorCore's VersionsManager — never here.
+ */
+interface VersionsState {
+ isPanelOpen: boolean;
+ isCheckpointDialogOpen: boolean;
+ setPanelOpen: (args: { open: boolean }) => void;
+ togglePanel: () => void;
+ openCheckpointDialog: () => void;
+ closeCheckpointDialog: () => void;
+}
+
+export const useVersionsStore = create<VersionsState>()((set) => ({
+ isPanelOpen: false,
+ isCheckpointDialogOpen: false,
+ setPanelOpen: ({ open }) => set({ isPanelOpen: open }),
+ togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })),
+ openCheckpointDialog: () => set({ isCheckpointDialogOpen: true }),
+ closeCheckpointDialog: () => set({ isCheckpointDialogOpen: false }),
+}));