mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user