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/versions-manager.ts | 503 ++++++++++++++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 apps/web/src/versions/versions-manager.ts (limited to 'apps/web/src/versions/versions-manager.ts') 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"; +} -- cgit v1.3.1