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
```
This commit is contained in:
Vance Ingalls
2026-05-03 23:06:11 -07:00
committed by GitHub
parent 1d15845a13
commit d0abe90a82
82 changed files with 15351 additions and 717 deletions
+218
View File
@@ -0,0 +1,218 @@
export type EditHistoryKind = "manual" | "timeline" | "source";
export interface EditHistoryFileSnapshot {
before: string;
after: string;
beforeHash: string;
afterHash: string;
}
export interface EditHistoryEntry {
id: string;
projectId: string;
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
createdAt: number;
files: Record<string, EditHistoryFileSnapshot>;
}
export interface EditHistoryState {
version: 1;
updatedAt: number;
undo: EditHistoryEntry[];
redo: EditHistoryEntry[];
}
export interface EditHistoryOptions {
maxEntries?: number;
coalesceMs?: number;
}
export interface BuildEditHistoryEntryInput {
id: string;
projectId: string;
label: string;
kind?: EditHistoryKind;
coalesceKey?: string;
now: number;
files: Record<string, { before: string; after: string }>;
}
export type EditHistoryDirection = "undo" | "redo";
export type EditHistoryApplyCheck =
| { ok: true }
| { ok: false; reason: "content-mismatch"; path: string };
export type EditHistoryTransitionResult =
| {
ok: true;
state: EditHistoryState;
entry: EditHistoryEntry;
filesToWrite: Record<string, string>;
}
| {
ok: false;
reason: "empty" | "content-mismatch";
state: EditHistoryState;
filesToWrite: Record<string, string>;
path?: string;
};
const DEFAULT_MAX_ENTRIES = 100;
const DEFAULT_COALESCE_MS = 1500;
export function hashEditHistoryContent(content: string): string {
let hash = 2166136261;
for (let index = 0; index < content.length; index += 1) {
hash ^= content.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
export function createEmptyEditHistory(_options?: EditHistoryOptions): EditHistoryState {
return {
version: 1,
updatedAt: 0,
undo: [],
redo: [],
};
}
export function buildEditHistoryEntry(input: BuildEditHistoryEntryInput): EditHistoryEntry {
const files: Record<string, EditHistoryFileSnapshot> = {};
for (const [path, snapshot] of Object.entries(input.files)) {
if (snapshot.before === snapshot.after) continue;
files[path] = {
before: snapshot.before,
after: snapshot.after,
beforeHash: hashEditHistoryContent(snapshot.before),
afterHash: hashEditHistoryContent(snapshot.after),
};
}
return {
id: input.id,
projectId: input.projectId,
label: input.label,
kind: input.kind ?? "manual",
coalesceKey: input.coalesceKey,
createdAt: input.now,
files,
};
}
export function pushEditHistoryEntry(
state: EditHistoryState,
entry: EditHistoryEntry,
options?: EditHistoryOptions,
): EditHistoryState {
if (Object.keys(entry.files).length === 0) return state;
const coalesceMs = options?.coalesceMs ?? DEFAULT_COALESCE_MS;
const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
const previous = state.undo[state.undo.length - 1];
let undo = state.undo;
if (
previous &&
previous.coalesceKey &&
previous.coalesceKey === entry.coalesceKey &&
entry.createdAt - previous.createdAt <= coalesceMs
) {
const files: Record<string, EditHistoryFileSnapshot> = {};
for (const [path, snapshot] of Object.entries(entry.files)) {
const previousSnapshot = previous.files[path];
files[path] = previousSnapshot
? {
before: previousSnapshot.before,
after: snapshot.after,
beforeHash: previousSnapshot.beforeHash,
afterHash: snapshot.afterHash,
}
: snapshot;
}
undo = [...state.undo.slice(0, -1), { ...entry, files }];
} else {
undo = [...state.undo, entry];
}
return {
version: 1,
updatedAt: entry.createdAt,
undo: undo.slice(Math.max(0, undo.length - maxEntries)),
redo: [],
};
}
export function canApplyEditHistoryEntry(
entry: EditHistoryEntry,
direction: EditHistoryDirection,
currentHashes: Record<string, string>,
): EditHistoryApplyCheck {
for (const [path, snapshot] of Object.entries(entry.files)) {
const expected = direction === "undo" ? snapshot.afterHash : snapshot.beforeHash;
if (currentHashes[path] !== expected) {
return { ok: false, reason: "content-mismatch", path };
}
}
return { ok: true };
}
export function undoEditHistory(
state: EditHistoryState,
currentHashes: Record<string, string>,
now: number,
): EditHistoryTransitionResult {
const entry = state.undo[state.undo.length - 1];
if (!entry) return { ok: false, reason: "empty", state, filesToWrite: {} };
const check = canApplyEditHistoryEntry(entry, "undo", currentHashes);
if (!check.ok) {
return { ok: false, reason: check.reason, path: check.path, state, filesToWrite: {} };
}
return {
ok: true,
entry,
filesToWrite: Object.fromEntries(
Object.entries(entry.files).map(([path, snapshot]) => [path, snapshot.before]),
),
state: {
version: 1,
updatedAt: now,
undo: state.undo.slice(0, -1),
redo: [...state.redo, entry],
},
};
}
export function redoEditHistory(
state: EditHistoryState,
currentHashes: Record<string, string>,
now: number,
): EditHistoryTransitionResult {
const entry = state.redo[state.redo.length - 1];
if (!entry) return { ok: false, reason: "empty", state, filesToWrite: {} };
const check = canApplyEditHistoryEntry(entry, "redo", currentHashes);
if (!check.ok) {
return { ok: false, reason: check.reason, path: check.path, state, filesToWrite: {} };
}
return {
ok: true,
entry,
filesToWrite: Object.fromEntries(
Object.entries(entry.files).map(([path, snapshot]) => [path, snapshot.after]),
),
state: {
version: 1,
updatedAt: now,
undo: [...state.undo, entry],
redo: state.redo.slice(0, -1),
},
};
}