mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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,11 +30,14 @@ function studioPositionSeekReapplyRuntime(): void {
|
||||
const ROTATION_ATTR = "data-hf-studio-rotation";
|
||||
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
|
||||
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
|
||||
const MOTION_ATTR = "data-hf-studio-motion";
|
||||
const MOTION_TL_KEY = "studio-motion";
|
||||
const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped";
|
||||
|
||||
if (
|
||||
!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') &&
|
||||
!document.querySelector("[" + ROTATION_ATTR + '="true"]')
|
||||
!document.querySelector("[" + ROTATION_ATTR + '="true"]') &&
|
||||
!document.querySelector("[" + MOTION_ATTR + "]")
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -77,6 +80,117 @@ function studioPositionSeekReapplyRuntime(): void {
|
||||
return "calc(" + original + " + " + rotationValue + ")";
|
||||
};
|
||||
|
||||
let lastSeekTime = 0;
|
||||
let cachedMotionKey = "";
|
||||
|
||||
const finiteNum = (v: unknown): number | null =>
|
||||
typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
|
||||
const computeMotionKey = (motionEls: NodeListOf<Element>): string => {
|
||||
let key = "";
|
||||
for (let i = 0; i < motionEls.length; i++) {
|
||||
const json = (motionEls[i] as HTMLElement).getAttribute?.(MOTION_ATTR);
|
||||
if (json) key += (key ? "\n" : "") + json;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const reapplyMotionTimeline = (): void => {
|
||||
const motionEls = document.querySelectorAll("[" + MOTION_ATTR + "]");
|
||||
if (motionEls.length === 0) {
|
||||
cachedMotionKey = "";
|
||||
return;
|
||||
}
|
||||
const win = window as Window & {
|
||||
gsap?: {
|
||||
timeline?: (opts: Record<string, unknown>) => Record<string, unknown>;
|
||||
set?: (el: HTMLElement, vars: Record<string, unknown>) => void;
|
||||
registerPlugin?: (plugin: unknown) => void;
|
||||
};
|
||||
CustomEase?: { create?: (id: string, data: string) => void };
|
||||
__timelines?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const gsap = win.gsap;
|
||||
if (!gsap || typeof gsap.timeline !== "function") return;
|
||||
win.__timelines = win.__timelines || {};
|
||||
|
||||
// Cache the timeline keyed by the concatenated motion JSON strings.
|
||||
// On each seek, if the key hasn't changed, just seek the existing timeline
|
||||
// instead of rebuilding it (avoids kill+recreate on every frame).
|
||||
const motionKey = computeMotionKey(motionEls);
|
||||
const existing = win.__timelines[MOTION_TL_KEY];
|
||||
if (
|
||||
motionKey &&
|
||||
motionKey === cachedMotionKey &&
|
||||
existing &&
|
||||
typeof existing.totalTime === "function"
|
||||
) {
|
||||
(existing.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing && typeof existing.kill === "function") (existing.kill as () => void)();
|
||||
const tl = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } });
|
||||
const fromTo = tl.fromTo as (
|
||||
el: HTMLElement,
|
||||
from: Record<string, unknown>,
|
||||
to: Record<string, unknown>,
|
||||
pos: number,
|
||||
) => void;
|
||||
if (typeof fromTo !== "function") return;
|
||||
let applied = 0;
|
||||
for (let i = 0; i < motionEls.length; i++) {
|
||||
const el = motionEls[i] as HTMLElement;
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
const json = el.getAttribute(MOTION_ATTR);
|
||||
if (!json) continue;
|
||||
try {
|
||||
const m = JSON.parse(json) as Record<string, unknown>;
|
||||
const start = finiteNum(m.start);
|
||||
const duration = finiteNum(m.duration);
|
||||
if (start == null || duration == null || duration <= 0) continue;
|
||||
const ease = typeof m.ease === "string" ? m.ease : "none";
|
||||
const from = (m.from && typeof m.from === "object" ? m.from : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const to = (m.to && typeof m.to === "object" ? m.to : {}) as Record<string, unknown>;
|
||||
const customEase = m.customEase as { id?: string; data?: string } | null | undefined;
|
||||
let resolvedEase = ease;
|
||||
if (customEase?.id && customEase?.data && win.CustomEase?.create) {
|
||||
try {
|
||||
gsap.registerPlugin?.(win.CustomEase);
|
||||
win.CustomEase.create(customEase.id, customEase.data);
|
||||
resolvedEase = customEase.id;
|
||||
} catch {
|
||||
/* use default ease */
|
||||
}
|
||||
}
|
||||
fromTo.call(
|
||||
tl,
|
||||
el,
|
||||
{ ...from },
|
||||
{ ...to, duration, ease: resolvedEase, overwrite: "auto", immediateRender: false },
|
||||
start,
|
||||
);
|
||||
applied += 1;
|
||||
} catch {
|
||||
/* malformed JSON — skip */
|
||||
}
|
||||
}
|
||||
if (applied === 0) {
|
||||
cachedMotionKey = "";
|
||||
if (typeof (tl as { kill?: () => void }).kill === "function")
|
||||
(tl as { kill: () => void }).kill();
|
||||
return;
|
||||
}
|
||||
cachedMotionKey = motionKey;
|
||||
win.__timelines[MOTION_TL_KEY] = tl;
|
||||
if (typeof tl.pause === "function") (tl.pause as () => void)();
|
||||
if (typeof tl.totalTime === "function")
|
||||
(tl.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
|
||||
};
|
||||
|
||||
const reapplyAll = (): void => {
|
||||
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
|
||||
for (let i = 0; i < offsetEls.length; i++) {
|
||||
@@ -104,6 +218,7 @@ function studioPositionSeekReapplyRuntime(): void {
|
||||
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
|
||||
}
|
||||
}
|
||||
reapplyMotionTimeline();
|
||||
};
|
||||
|
||||
const runtimeWindow = window as Window & {
|
||||
@@ -139,6 +254,7 @@ function studioPositionSeekReapplyRuntime(): void {
|
||||
return true;
|
||||
}
|
||||
const wrapped = function (this: unknown, time: number): unknown {
|
||||
lastSeekTime = typeof time === "number" && Number.isFinite(time) ? Math.max(0, time) : 0;
|
||||
const result = seek.call(this, time);
|
||||
reapplyAll();
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user