mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
feat(studio): html-backed motion panel (#873)
## Summary
Re-architects the studio motion panel to persist GSAP motion data directly in HTML element attributes instead of a `.hyperframes/studio-motion.json` JSON sidecar file. Same pattern as position/resize/rotation edits.
### Before
```
MotionPanel → commitStudioMotionManifestOptimistically()
→ writes .hyperframes/studio-motion.json
→ applyStudioMotionManifest(doc, manifest)
```
### After
```html
<div id="hero" data-hf-studio-motion='{"start":0.5,"duration":1,"ease":"power3.out","from":{"opacity":0,"y":40},"to":{"opacity":1,"y":0}}'>
```
```
MotionPanel → writeStudioMotionToElement(element, motion)
→ buildMotionPatches(element)
→ commitPositionPatchToHtml(selection, patches)
```
## What changed
- **studioMotionOps.ts** — Added `readStudioMotionFromElement()`, `writeStudioMotionToElement()`, `clearStudioMotionFromElement()` for attribute-based CRUD
- **studioMotion.ts** — Added `applyStudioMotionFromDom()` that reads motion from DOM attributes and builds GSAP timeline (kept `applyStudioMotionManifest` for render script compat)
- **manualEditsDom.ts** — Added `buildMotionPatches()` / `buildClearMotionPatches()`, integrated motion into `reapplyPositionEditsAfterSeek()`
- **useDomEditCommits.ts** — Rewrote `handleDomMotionCommit` / `handleDomMotionClear` to use HTML patching instead of manifest persistence
- **useManifestPersistence.ts** — Removed all motion manifest state (~200 lines): `studioMotionManifestRef`, `commitStudioMotionManifestOptimistically`, `applyStudioMotionToPreview`, motion SSE handler
- **App.tsx** — Reads motion from element attribute (`readStudioMotionFromElement`) instead of manifest ref
- **manualEditsRenderScript.ts** — Extended `studioPositionSeekReapplyRuntime` to rebuild GSAP motion timeline from `data-hf-studio-motion` attributes after each seek, including CustomEase support
- **htmlCompiler.ts** — Trigger seek-reapply script injection on `data-hf-studio-motion=` attributes
## Benefits
- No sidecar file — motion survives git, copy-paste, and manual HTML editing
- Undo/redo works via HTML source history (same as position edits)
- Renders correctly via CLI — seek-reapply script handles motion timeline rebuild
- Simpler architecture — one persistence path for all studio edits
## Test plan
- [x] `bun run build` passes
- [x] Pre-commit hooks pass (lint, format, typecheck)
- [ ] Set motion on element in Studio → `data-hf-studio-motion` attribute appears in HTML source
- [ ] Reload page → motion persists and plays correctly
- [ ] Clear motion → attribute removed, element returns to original state
- [ ] Undo/redo motion changes
- [ ] Render via CLI → motion visible in rendered video
- [ ] Seek animation → motion timeline re-syncs correctly
This commit is contained in:
@@ -20,12 +20,14 @@ import {
|
||||
buildClearPathOffsetPatches,
|
||||
buildClearBoxSizePatches,
|
||||
buildClearRotationPatches,
|
||||
buildMotionPatches,
|
||||
buildClearMotionPatches,
|
||||
} from "../components/editor/manualEditsDom";
|
||||
import {
|
||||
removeStudioMotionForSelection,
|
||||
writeStudioMotionToElement,
|
||||
clearStudioMotionFromElement,
|
||||
applyStudioMotionFromDom,
|
||||
type StudioGsapMotion,
|
||||
type StudioMotionManifest,
|
||||
upsertStudioGsapMotion,
|
||||
} from "../components/editor/studioMotion";
|
||||
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
||||
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
||||
@@ -58,11 +60,6 @@ export interface UseDomEditCommitsParams {
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
||||
commitStudioMotionManifestOptimistically: (
|
||||
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => void;
|
||||
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
||||
@@ -93,8 +90,6 @@ export function useDomEditCommits({
|
||||
previewIframeRef,
|
||||
showToast,
|
||||
queueDomEditSave,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
applyCurrentStudioMotionToPreview,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
editHistory,
|
||||
@@ -306,43 +301,60 @@ export function useDomEditCommits({
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
|
||||
// ── Motion commits ──
|
||||
// ── Motion commits (HTML-attribute–backed) ──
|
||||
|
||||
const handleDomMotionCommit = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
|
||||
) => {
|
||||
commitStudioMotionManifestOptimistically(
|
||||
(manifest) => upsertStudioGsapMotion(manifest, selection, motion),
|
||||
{
|
||||
label: "Set GSAP motion",
|
||||
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
// 1. Write motion data as JSON attribute on the element
|
||||
writeStudioMotionToElement(selection.element, motion);
|
||||
// 2. Apply the GSAP timeline from DOM attributes
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = previewIframeRef.current?.contentDocument ?? null;
|
||||
} catch {
|
||||
// cross-origin guard
|
||||
}
|
||||
if (doc) applyStudioMotionFromDom(doc);
|
||||
// 3. Build patches and persist to HTML
|
||||
const patches = buildMotionPatches(selection.element);
|
||||
commitPositionPatchToHtml(selection, patches, {
|
||||
label: "Set GSAP motion",
|
||||
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
||||
});
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
|
||||
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
|
||||
);
|
||||
|
||||
const handleDomMotionClear = useCallback(
|
||||
(selection: DomEditSelection) => {
|
||||
commitStudioMotionManifestOptimistically(
|
||||
(manifest) => removeStudioMotionForSelection(manifest, selection),
|
||||
{
|
||||
label: "Clear GSAP motion",
|
||||
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
||||
const clearPatches = buildClearMotionPatches(selection.element);
|
||||
// Get gsap from the preview window for proper cleanup
|
||||
let gsap: { set?: (target: HTMLElement, vars: Record<string, unknown>) => void } | undefined;
|
||||
try {
|
||||
gsap = (previewIframeRef.current?.contentWindow as { gsap?: typeof gsap })?.gsap;
|
||||
} catch {
|
||||
// cross-origin guard
|
||||
}
|
||||
clearStudioMotionFromElement(selection.element, gsap);
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = previewIframeRef.current?.contentDocument ?? null;
|
||||
} catch {
|
||||
// cross-origin guard
|
||||
}
|
||||
if (doc) applyStudioMotionFromDom(doc);
|
||||
commitPositionPatchToHtml(selection, clearPatches, {
|
||||
label: "Clear GSAP motion",
|
||||
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
||||
skipRefresh: false,
|
||||
});
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[
|
||||
applyCurrentStudioMotionToPreview,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
previewIframeRef,
|
||||
],
|
||||
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
|
||||
);
|
||||
|
||||
const handleDomEditElementDelete = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user