mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +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:
@@ -30,8 +30,12 @@ export {
|
||||
upsertStudioGsapMotion,
|
||||
removeStudioMotionForSelection,
|
||||
getStudioMotionForSelection,
|
||||
readStudioMotionFromElement,
|
||||
writeStudioMotionToElement,
|
||||
clearStudioMotionFromElement,
|
||||
} from "./studioMotionOps";
|
||||
|
||||
import { readStudioMotionFromElement as readMotionAttr } from "./studioMotionOps";
|
||||
import {
|
||||
STUDIO_MOTION_ATTR,
|
||||
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
|
||||
@@ -39,6 +43,7 @@ import {
|
||||
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
|
||||
STUDIO_MOTION_TIMELINE_ID,
|
||||
type StudioGsapMotion,
|
||||
type StudioGsapMotionValues,
|
||||
type StudioMotionManifest,
|
||||
type StudioMotionTarget,
|
||||
type StudioMotionWindow,
|
||||
@@ -220,6 +225,97 @@ export function applyStudioMotionManifest(
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads motion data from `data-hf-studio-motion` JSON attributes in the DOM,
|
||||
* builds a GSAP timeline, and seeks to the current time.
|
||||
* This replaces the manifest-based `applyStudioMotionManifest` for the studio preview.
|
||||
*/
|
||||
export function applyStudioMotionFromDom(document: Document, currentTime?: number): number {
|
||||
const win = document.defaultView as StudioMotionWindow | null;
|
||||
if (!win) return 0;
|
||||
const gsap = win.gsap;
|
||||
win.__timelines = win.__timelines ?? {};
|
||||
win.__timelines[STUDIO_MOTION_TIMELINE_ID]?.kill?.();
|
||||
delete win.__timelines[STUDIO_MOTION_TIMELINE_ID];
|
||||
|
||||
// Restore elements that had GSAP motion applied previously but whose attribute
|
||||
// is now just the legacy marker "true" (i.e. they were restored/cleared).
|
||||
const HTMLElementCtor = document.defaultView?.HTMLElement;
|
||||
if (!HTMLElementCtor) return 0;
|
||||
|
||||
// Collect elements that have JSON motion data in their attribute
|
||||
const motionElements: Array<{
|
||||
element: HTMLElement;
|
||||
motion: {
|
||||
start: number;
|
||||
duration: number;
|
||||
ease: string;
|
||||
customEase?: { id: string; data: string };
|
||||
from: StudioGsapMotionValues;
|
||||
to: StudioGsapMotionValues;
|
||||
};
|
||||
}> = [];
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) {
|
||||
if (!(el instanceof HTMLElementCtor)) continue;
|
||||
const motionData = readMotionAttr(el);
|
||||
if (motionData) {
|
||||
motionElements.push({ element: el, motion: motionData });
|
||||
}
|
||||
}
|
||||
|
||||
if (!gsap?.timeline || motionElements.length === 0) return 0;
|
||||
|
||||
const timeline = gsap.timeline({
|
||||
paused: true,
|
||||
defaults: { overwrite: "auto" },
|
||||
});
|
||||
let applied = 0;
|
||||
for (const { element, motion } of motionElements) {
|
||||
if (!timeline.fromTo) continue;
|
||||
// Original styles are already captured when writeStudioMotionToElement was called
|
||||
const fromVars: Record<string, unknown> = { ...motion.from };
|
||||
const ease = resolveGsapEaseFromPayload(win, motion);
|
||||
const toVars: Record<string, unknown> = {
|
||||
...motion.to,
|
||||
duration: motion.duration,
|
||||
ease,
|
||||
overwrite: "auto",
|
||||
immediateRender: false,
|
||||
};
|
||||
timeline.fromTo(element, fromVars, toVars, motion.start);
|
||||
applied += 1;
|
||||
}
|
||||
|
||||
if (applied === 0) {
|
||||
timeline.kill?.();
|
||||
return 0;
|
||||
}
|
||||
win.__timelines[STUDIO_MOTION_TIMELINE_ID] = timeline;
|
||||
timeline.pause?.();
|
||||
const safeTime = readCurrentTime(win, currentTime);
|
||||
if (timeline.totalTime) timeline.totalTime(safeTime, false);
|
||||
else timeline.time?.(safeTime);
|
||||
return applied;
|
||||
}
|
||||
|
||||
function resolveGsapEaseFromPayload(
|
||||
win: StudioMotionWindow,
|
||||
motion: { ease: string; customEase?: { id: string; data: string } },
|
||||
): string {
|
||||
const customEase = motion.customEase;
|
||||
if (!customEase) return motion.ease;
|
||||
const customEasePlugin = win.CustomEase;
|
||||
if (typeof customEasePlugin?.create !== "function") return motion.ease;
|
||||
try {
|
||||
win.gsap?.registerPlugin?.(customEasePlugin);
|
||||
customEasePlugin.create(customEase.id, customEase.data);
|
||||
return customEase.id;
|
||||
} catch {
|
||||
return motion.ease;
|
||||
}
|
||||
}
|
||||
|
||||
export function installStudioMotionSeekReapply(win: Window, apply: () => void): boolean {
|
||||
const studioWin = win as StudioMotionWindow;
|
||||
studioWin.__hfStudioMotionApply = () => {
|
||||
|
||||
Reference in New Issue
Block a user