Files
hyperframes/packages/studio/src/utils/editHistoryStorage.ts
T
Vance Ingalls d0abe90a82 feat: Persist Studio manual edits via manifest (#593)
## Summary

Studio manual geometry edits now persist as a project-local manifest instead of being baked into composition source on each gesture.

The manifest lives at:

```text
.hyperframes/studio-manual-edits.json
```

It is the source of truth for manual drag, resize, rotation, inspector geometry edits, group moves, and selected-layer reset.

## Architecture

- **Manifest-backed edits**: each edit stores a kind (`path-offset`, `box-size`, `rotation`), a source-scoped target, and the edit values.
- **Source-scoped resolution**: targets include `sourceFile`, `id`, `selector`, and `selectorIndex`, so duplicate selectors in nested compositions resolve against the owning source file.
- **Additive CSS layer**: move uses CSS `translate`, resize writes stable dimensions/flex sizing, and rotation uses CSS `rotate` over the authored base.
- **Shared replay runtime**: Studio preview, thumbnails, frame capture, producer renders, and CLI Studio renders/thumbnails all use the same core manual-edit render script.
- **Animation-safe replay**: Studio reapplies the manual layer after load, refresh, timeline seeks, player operations, playback frames, thumbnail seeks, and render seeks instead of rewriting GSAP timelines.
- **History and handoff**: the manifest is a normal project file, so undo/redo and agent edits can preserve, modify, or remove manual visual edits explicitly.

## User Impact

Users can move, resize, rotate, group-move, and reset supported layers from the canvas or inspector, then refresh, capture thumbnails/screenshots, play animated compositions, and render videos without manual edits drifting away from the edited state.

## Main Files

- `packages/studio/src/components/editor/manualEdits.ts`
- `packages/studio/src/components/editor/DomEditOverlay.tsx`
- `packages/studio/src/components/editor/PropertyPanel.tsx`
- `packages/studio/src/App.tsx`
- `packages/core/src/studio-api/helpers/manualEditsRenderScript.ts`
- `packages/studio/vite.config.ts`
- `packages/cli/src/server/studioServer.ts`
- `packages/core/src/compiler/htmlBundler.ts`
- `packages/producer/src/services/htmlCompiler.ts`
- `packages/core/src/studio-api/routes/thumbnail.ts`
- `packages/producer/src/services/fileServer.ts`
- `packages/producer/src/services/renderOrchestrator.ts`

## Test Plan

```bash
volta run --node 22.20.0 bun run build
volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/studio-api/helpers/manualEditsRenderScript.test.ts
volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/cli typecheck
volta run --node 22.20.0 bunx oxlint <changed files>
volta run --node 22.20.0 bunx oxfmt --check <changed files>
git diff --check
```
2026-05-03 23:06:11 -07:00

100 lines
3.0 KiB
TypeScript

import { createEmptyEditHistory, type EditHistoryState } from "./editHistory";
export interface EditHistoryStorageAdapter {
get(projectId: string): Promise<EditHistoryState | null>;
set(projectId: string, state: EditHistoryState): Promise<void>;
delete(projectId: string): Promise<void>;
}
const DB_NAME = "hyperframes-studio-edit-history";
const DB_VERSION = 1;
const STORE_NAME = "project-history";
export function createMemoryEditHistoryStorage(): EditHistoryStorageAdapter {
const states = new Map<string, EditHistoryState>();
return {
async get(projectId) {
return states.get(projectId) ?? null;
},
async set(projectId, state) {
states.set(projectId, structuredClone(state));
},
async delete(projectId) {
states.delete(projectId);
},
};
}
function openEditHistoryDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
if (!globalThis.indexedDB) {
reject(new Error("IndexedDB is not available"));
return;
}
const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onerror = () => reject(request.error ?? new Error("Failed to open edit history db"));
request.onsuccess = () => resolve(request.result);
});
}
function withStore<T>(
mode: IDBTransactionMode,
callback: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return openEditHistoryDb().then(
(db) =>
new Promise<T>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, mode);
const request = callback(tx.objectStore(STORE_NAME));
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
request.onsuccess = () => resolve(request.result);
tx.oncomplete = () => db.close();
tx.onerror = () => {
db.close();
reject(tx.error ?? new Error("IndexedDB transaction failed"));
};
}),
);
}
export function createIndexedDbEditHistoryStorage(): EditHistoryStorageAdapter {
return {
async get(projectId) {
return (
(await withStore<EditHistoryState | undefined>("readonly", (store) =>
store.get(projectId),
)) ?? null
);
},
async set(projectId, state) {
await withStore<IDBValidKey>("readwrite", (store) => store.put(state, projectId));
},
async delete(projectId) {
await withStore<undefined>("readwrite", (store) => store.delete(projectId));
},
};
}
export async function loadEditHistoryState(
storage: EditHistoryStorageAdapter,
projectId: string,
): Promise<EditHistoryState> {
const state = await storage.get(projectId);
return state ?? createEmptyEditHistory();
}
export async function saveEditHistoryState(
storage: EditHistoryStorageAdapter,
projectId: string,
state: EditHistoryState,
): Promise<void> {
await storage.set(projectId, state);
}