diff options
20 files changed, 1709 insertions, 83 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 }), +})); diff --git a/notes/project-versioning.md b/notes/project-versioning.md index 607e4501..bf7b5c69 100644 --- a/notes/project-versioning.md +++ b/notes/project-versioning.md @@ -45,21 +45,21 @@ position stays reachable. - Ring buffer: keep the newest **20** auto versions per project; older auto versions are deleted on write. Named versions never rotate. -### Media storage +### Media storage (revised for v1) -Snapshots share media, they don't copy it. Media blobs move to a -**content-addressed store**: `mediaBlobs: { hash, blob }` with -`MediaAsset` records referencing hashes. A blob is collected only when no -project document and no version snapshot references it. This keeps 50 -snapshots of a project with one 200 MB video at ~one video's cost. +v1 keeps the existing per-project media store as-is: snapshots store the +`SerializedProject` and reference media by asset id. Deleting a media asset +is blocked (or the delete cascades a warning) while any version of the +project still references it, so a restore can never hit a missing blob. +Content-addressed blob dedup across snapshots is deferred to v2 — it is a +media-storage rework that would collide with concurrent export/voice-over +work for little practical gain at snapshot counts we expect. ## Storage layer -New versioned migration (next schema version after current v31) adding two -stores: `projectVersions` and `mediaBlobs`. Follow the existing per-version -migration pattern and its 18-test fixture suite -(`services/storage/migrations/__tests__/`). Existing per-project media -records get re-pointed at hashed blobs by the migration. +New versioned migration (v31→v32; v31 is current) adding one store: +`projectVersions`. Follow the existing per-version migration pattern and its +18-test fixture suite (`services/storage/migrations/__tests__/`). ## Surfaces (all via the actions system, per repo conventions) |
