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
@@ -89,7 +89,6 @@ export const NLELayout = memo(function NLELayout({
togglePlay,
seek,
onIframeLoad: baseOnIframeLoad,
refreshPlayer,
saveSeekPosition,
} = useTimelinePlayer();
@@ -103,13 +102,15 @@ export const NLELayout = memo(function NLELayout({
usePlayerStore.getState().reset();
}
// Refresh the existing iframe in place when source files change.
// Save seek position before the Player component creates a new player
// on refreshKey change. The Player handles the actual reload via the
// dual-player crossfade; we just need to persist the current time.
const prevRefreshKeyRef = useRef(refreshKey);
useEffect(() => {
if (refreshKey === prevRefreshKeyRef.current) return;
prevRefreshKeyRef.current = refreshKey;
refreshPlayer();
}, [refreshKey, refreshPlayer]);
saveSeekPosition();
}, [refreshKey, saveSeekPosition]);
// Wrap onIframeLoad to also notify parent of iframe ref
const onIframeLoad = useCallback(() => {
@@ -209,6 +210,10 @@ export const NLELayout = memo(function NLELayout({
const currentLevel = compositionStack[compositionStack.length - 1];
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
useEffect(() => {
onIframeRef?.(iframeRef.current);
}, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
// Save master seek position before drilling down so we can restore it on back-navigation.
// saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
const masterSeekRef = useRef(0);
@@ -1,4 +1,4 @@
import { memo, type Ref } from "react";
import { memo, useRef, useState, type Ref } from "react";
import { Player } from "../../player";
interface NLEPreviewProps {
@@ -21,6 +21,17 @@ export function getPreviewPlayerKey({
return directUrl ?? projectId;
}
/**
* Manages the composition preview with crossfade on reload.
*
* When refreshKey changes, a new Player is mounted alongside the old one.
* The old Player stays visible (opacity 1) until the new one fires onLoad,
* at which point the old is removed. This avoids the flash that a simple
* key-swap remount would cause.
*
* Uses the render-time state adjustment pattern (React-sanctioned) to detect
* refreshKey changes — no useEffect needed.
*/
export const NLEPreview = memo(function NLEPreview({
projectId,
iframeRef,
@@ -29,22 +40,56 @@ export const NLEPreview = memo(function NLEPreview({
directUrl,
refreshKey,
}: NLEPreviewProps) {
const playerKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
const prevRefreshKeyRef = useRef(refreshKey);
const [retiringKey, setRetiringKey] = useState<string | null>(null);
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Detect refreshKey change during render (React-sanctioned derived state pattern).
// When the key changes, the current active player becomes the retiring player
// and a new active player is mounted alongside it.
if (refreshKey !== prevRefreshKeyRef.current) {
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
prevRefreshKeyRef.current = refreshKey;
setRetiringKey(oldKey);
}
const activeKey = `${baseKey}:${refreshKey ?? 0}`;
const handleNewPlayerLoad = () => {
onIframeLoad();
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
retiringTimerRef.current = setTimeout(() => {
setRetiringKey(null);
retiringTimerRef.current = null;
}, 160);
};
return (
<div className="flex flex-col h-full min-h-0">
<div
className="flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
tabIndex={0}
aria-label="Composition preview"
>
{retiringKey && (
<Player
key={retiringKey}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={() => {}}
portrait={portrait}
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
/>
)}
<Player
key={playerKey}
key={activeKey}
ref={iframeRef}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={onIframeLoad}
onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
portrait={portrait}
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
/>
</div>
</div>