From d55d2583a71ae35fa8d0d1bfbec975e67e66aafa Mon Sep 17 00:00:00 2001 From: cashmere Date: Mon, 27 Jul 2026 01:21:20 +0200 Subject: feat: git-like project versioning (v1) Linear per-project history per notes/project-versioning.md: named checkpoints (thumbnail via renderer.captureFrame) plus auto-checkpoints on save (60s throttle, ring of 20, named never rotate). Restore writes a safety checkpoint first, then swaps the document outside undo/redo. Media assets referenced by any version are pinned against deletion. Migration v31-v32 stamps the schema; the version store lives in its own IDB database with an in-memory fallback for tests. Versions popover in the editor header for jump/rename/delete. 12 new tests. --- apps/web/src/versions/__tests__/media-pin.test.ts | 137 ++++++ .../versions/__tests__/versions-manager.test.ts | 242 ++++++++++ .../versions/components/version-name-dialog.tsx | 77 ++++ .../src/versions/components/versions-popover.tsx | 320 +++++++++++++ apps/web/src/versions/index.ts | 13 + apps/web/src/versions/media-references.ts | 52 +++ apps/web/src/versions/store.ts | 86 ++++ apps/web/src/versions/types.ts | 21 + apps/web/src/versions/versions-manager.ts | 503 +++++++++++++++++++++ apps/web/src/versions/versions-store.ts | 23 + 10 files changed, 1474 insertions(+) create mode 100644 apps/web/src/versions/__tests__/media-pin.test.ts create mode 100644 apps/web/src/versions/__tests__/versions-manager.test.ts create mode 100644 apps/web/src/versions/components/version-name-dialog.tsx create mode 100644 apps/web/src/versions/components/versions-popover.tsx create mode 100644 apps/web/src/versions/index.ts create mode 100644 apps/web/src/versions/media-references.ts create mode 100644 apps/web/src/versions/store.ts create mode 100644 apps/web/src/versions/types.ts create mode 100644 apps/web/src/versions/versions-manager.ts create mode 100644 apps/web/src/versions/versions-store.ts (limited to 'apps/web/src/versions') 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 ( + + + + {title} + + + + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onConfirm(name); + } + }} + placeholder="e.g. Final cut" + /> + + + + + + + + + ); +} 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( + null, + ); + const [renameTarget, setRenameTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(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 ( + <> + + + + + +
+ Versions + +
+ {versions.length === 0 ? ( +

+ No versions yet. Create a checkpoint to save a known-good state. +

+ ) : ( + +
+ {versions.map((version) => ( + setRestoreTarget(version)} + onRename={ + version.kind === "named" + ? () => setRenameTarget(version) + : undefined + } + onDelete={ + version.kind === "named" + ? () => setDeleteTarget(version) + : undefined + } + /> + ))} +
+
+ )} +
+
+ + { + if (!open) closeCheckpointDialog(); + }} + onConfirm={handleCreateCheckpoint} + mode="create" + /> + + { + 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 ?? ""} + /> + + { + if (!open) setRestoreTarget(null); + }} + > + + + + Restore {restoreTarget ? getVersionTitle({ version: restoreTarget }) : ""}? + + + The editor will jump to this version. Your current state is saved + as an auto-checkpoint first, so nothing is lost. + + + + Cancel + { + const target = restoreTarget; + setRestoreTarget(null); + if (target) { + editor.versions + .restoreVersion({ versionId: target.id }) + .catch((error) => { + console.error("Failed to restore version:", error); + }); + } + }} + > + Restore + + + + + + { + if (!open) setDeleteTarget(null); + }} + > + + + + Delete {deleteTarget ? getVersionTitle({ version: deleteTarget }) : ""}? + + + This checkpoint is removed permanently. The current state of the + project is not affected. + + + + Cancel + { + const target = deleteTarget; + setDeleteTarget(null); + if (target) { + editor.versions + .deleteVersion({ versionId: target.id }) + .catch((error) => { + console.error("Failed to delete checkpoint:", error); + }); + } + }} + > + Delete + + + + + + ); +} + +function VersionRow({ + version, + onRestore, + onRename, + onDelete, +}: { + version: ProjectVersion; + onRestore: () => void; + onRename?: () => void; + onDelete?: () => void; +}) { + return ( +
+ + {onRename && ( + + )} + {onDelete && ( + + )} +
+ ); +} 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function collectFromElements({ + elements, + mediaIds, +}: { + elements: unknown; + mediaIds: Set; +}): 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 { + const mediaIds = new Set(); + + 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; + getAllForProject({ + projectId, + }: { + projectId: string; + }): Promise; + delete({ id }: { id: string }): Promise; + deleteForProject({ projectId }: { projectId: string }): Promise; +} + +export class IndexedDBVersionStore implements VersionStore { + private adapter = new IndexedDBAdapter({ + dbName: "video-editor-project-versions", + storeName: "project-versions", + version: 1, + }); + + async put({ version }: { version: ProjectVersion }): Promise { + await this.adapter.set({ key: version.id, value: version }); + } + + async getAllForProject({ + projectId, + }: { + projectId: string; + }): Promise { + const all = await this.adapter.getAll(); + return all.filter( + (version) => + typeof version === "object" && + version !== null && + version.projectId === projectId, + ); + } + + async delete({ id }: { id: string }): Promise { + await this.adapter.remove(id); + } + + async deleteForProject({ projectId }: { projectId: string }): Promise { + 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(); + + async put({ version }: { version: ProjectVersion }): Promise { + this.versions.set(version.id, version); + } + + async getAllForProject({ + projectId, + }: { + projectId: string; + }): Promise { + return [...this.versions.values()].filter( + (version) => version.projectId === projectId, + ); + } + + async delete({ id }: { id: string }): Promise { + this.versions.delete(id); + } + + async deleteForProject({ projectId }: { projectId: string }): Promise { + 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 { + 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(); + private pinnedMediaByProject = new Map>(); + private lastAutoCheckpointAt = new Map(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + 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()((set) => ({ + isPanelOpen: false, + isCheckpointDialogOpen: false, + setPanelOpen: ({ open }) => set({ isPanelOpen: open }), + togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })), + openCheckpointDialog: () => set({ isCheckpointDialogOpen: true }), + closeCheckpointDialog: () => set({ isCheckpointDialogOpen: false }), +})); -- cgit v1.3.1