mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
## 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 ```
100 lines
3.0 KiB
TypeScript
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);
|
|
}
|