mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
## 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
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
// Public surface — re-exports types, constants, and ops; owns DOM application logic.
|
|
|
|
export {
|
|
STUDIO_MOTION_PATH,
|
|
STUDIO_MOTION_TIMELINE_ID,
|
|
STUDIO_GSAP_EASE_OPTIONS,
|
|
type StudioMotionTarget,
|
|
type StudioGsapMotionValues,
|
|
type StudioGsapCustomEase,
|
|
type StudioCustomEaseControlPoints,
|
|
type StudioGsapMotion,
|
|
type StudioGsapMotionPreset,
|
|
type StudioGsapMotionDirection,
|
|
type StudioGsapPresetMotionOptions,
|
|
type StudioMotionManifest,
|
|
type StudioGsapTimeline,
|
|
type StudioMotionWindow,
|
|
} from "./studioMotionTypes";
|
|
|
|
export {
|
|
clampStudioCustomEasePoints,
|
|
parseStudioCustomEaseData,
|
|
serializeStudioCustomEaseData,
|
|
controlPointsForGsapEase,
|
|
buildStudioGsapPresetMotion,
|
|
emptyStudioMotionManifest,
|
|
parseStudioMotionManifest,
|
|
serializeStudioMotionManifest,
|
|
isStudioMotionManifestPath,
|
|
upsertStudioGsapMotion,
|
|
removeStudioMotionForSelection,
|
|
getStudioMotionForSelection,
|
|
readStudioMotionFromElement,
|
|
writeStudioMotionToElement,
|
|
clearStudioMotionFromElement,
|
|
} from "./studioMotionOps";
|
|
|
|
import { readStudioMotionFromElement as readMotionAttr } from "./studioMotionOps";
|
|
import {
|
|
STUDIO_MOTION_ATTR,
|
|
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
|
|
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
|
|
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
|
|
STUDIO_MOTION_TIMELINE_ID,
|
|
type StudioGsapMotion,
|
|
type StudioGsapMotionValues,
|
|
type StudioMotionManifest,
|
|
type StudioMotionTarget,
|
|
type StudioMotionWindow,
|
|
} from "./studioMotionTypes";
|
|
|
|
// ── DOM Helpers ──
|
|
|
|
function sourceFileForElement(element: HTMLElement, activeCompositionPath: string | null): string {
|
|
let current: HTMLElement | null = element;
|
|
while (current) {
|
|
const sourceFile =
|
|
current.getAttribute("data-composition-file") ?? current.getAttribute("data-composition-src");
|
|
if (sourceFile) return sourceFile;
|
|
current = current.parentElement;
|
|
}
|
|
return activeCompositionPath ?? "index.html";
|
|
}
|
|
|
|
function elementMatchesSourceFile(
|
|
element: HTMLElement,
|
|
sourceFile: string,
|
|
activeCompositionPath: string | null,
|
|
): boolean {
|
|
return sourceFileForElement(element, activeCompositionPath) === sourceFile;
|
|
}
|
|
|
|
function querySelectorCandidates(document: Document, selector: string): HTMLElement[] {
|
|
const isCandidate = (element: Element): element is HTMLElement => {
|
|
const HTMLElementCtor = element.ownerDocument.defaultView?.HTMLElement;
|
|
return Boolean(HTMLElementCtor && element instanceof HTMLElementCtor);
|
|
};
|
|
const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
|
|
if (className) {
|
|
return Array.from(document.getElementsByTagName("*")).filter(
|
|
(element): element is HTMLElement =>
|
|
isCandidate(element) && element.classList.contains(className),
|
|
);
|
|
}
|
|
if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
|
|
return Array.from(document.getElementsByTagName(selector)).filter(isCandidate);
|
|
}
|
|
return Array.from(document.querySelectorAll(selector)).filter(isCandidate);
|
|
}
|
|
|
|
function resolveTarget(
|
|
document: Document,
|
|
target: StudioMotionTarget,
|
|
activeCompositionPath: string | null,
|
|
): HTMLElement | null {
|
|
const HTMLElementCtor = document.defaultView?.HTMLElement;
|
|
if (target.id) {
|
|
const byId = document.getElementById(target.id);
|
|
if (
|
|
HTMLElementCtor &&
|
|
byId instanceof HTMLElementCtor &&
|
|
elementMatchesSourceFile(byId, target.sourceFile, activeCompositionPath)
|
|
) {
|
|
return byId;
|
|
}
|
|
}
|
|
if (!target.selector) return null;
|
|
try {
|
|
const matches = querySelectorCandidates(document, target.selector).filter((element) =>
|
|
elementMatchesSourceFile(element, target.sourceFile, activeCompositionPath),
|
|
);
|
|
return matches[Math.max(0, Math.floor(target.selectorIndex ?? 0))] ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function captureOriginalMotionStyles(element: HTMLElement): void {
|
|
if (element.hasAttribute(STUDIO_MOTION_ATTR)) return;
|
|
element.setAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, element.style.transform);
|
|
element.setAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, element.style.opacity);
|
|
element.setAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, element.style.visibility);
|
|
}
|
|
|
|
function restoreStudioMotionElement(element: HTMLElement, gsap: StudioMotionWindow["gsap"]): void {
|
|
if (!element.hasAttribute(STUDIO_MOTION_ATTR)) return;
|
|
gsap?.set?.(element, { clearProps: "transform,opacity,visibility" });
|
|
element.style.transform = element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR) ?? "";
|
|
element.style.opacity = element.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR) ?? "";
|
|
element.style.visibility = element.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR) ?? "";
|
|
element.removeAttribute(STUDIO_MOTION_ATTR);
|
|
element.removeAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR);
|
|
element.removeAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR);
|
|
element.removeAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR);
|
|
}
|
|
|
|
function restoreStudioMotionElements(document: Document, gsap: StudioMotionWindow["gsap"]): void {
|
|
const HTMLElementCtor = document.defaultView?.HTMLElement;
|
|
if (!HTMLElementCtor) return;
|
|
for (const element of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) {
|
|
if (element instanceof HTMLElementCtor) restoreStudioMotionElement(element, gsap);
|
|
}
|
|
}
|
|
|
|
function resolveGsapEase(win: StudioMotionWindow, motion: StudioGsapMotion): 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;
|
|
}
|
|
}
|
|
|
|
function readCurrentTime(win: StudioMotionWindow, fallback?: number): number {
|
|
if (typeof fallback === "number" && Number.isFinite(fallback)) return Math.max(0, fallback);
|
|
try {
|
|
const playerTime = win.__player?.getTime?.();
|
|
if (typeof playerTime === "number" && Number.isFinite(playerTime))
|
|
return Math.max(0, playerTime);
|
|
} catch {
|
|
// fall through
|
|
}
|
|
try {
|
|
const timelineTime = win.__timeline?.time?.();
|
|
if (typeof timelineTime === "number" && Number.isFinite(timelineTime)) {
|
|
return Math.max(0, timelineTime);
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export function applyStudioMotionManifest(
|
|
document: Document,
|
|
manifest: StudioMotionManifest,
|
|
activeCompositionPath: string | null = null,
|
|
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];
|
|
restoreStudioMotionElements(document, gsap);
|
|
if (!gsap?.timeline || manifest.motions.length === 0) return 0;
|
|
|
|
const timeline = gsap.timeline({
|
|
paused: true,
|
|
defaults: { overwrite: "auto" },
|
|
});
|
|
let applied = 0;
|
|
for (const motion of manifest.motions) {
|
|
const element = resolveTarget(document, motion.target, activeCompositionPath);
|
|
if (!element || !timeline.fromTo) continue;
|
|
captureOriginalMotionStyles(element);
|
|
element.setAttribute(STUDIO_MOTION_ATTR, "true");
|
|
const fromVars: Record<string, unknown> = { ...motion.from };
|
|
const toVars: Record<string, unknown> = {
|
|
...motion.to,
|
|
duration: motion.duration,
|
|
ease: resolveGsapEase(win, motion),
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 = () => {
|
|
apply();
|
|
return 0;
|
|
};
|
|
if (studioWin.__hfStudioMotionWrapped) return false;
|
|
const player = studioWin.__player;
|
|
if (!player) return false;
|
|
|
|
const wrapPlayerMethod = (key: "renderSeek" | "seek") => {
|
|
const original = player[key];
|
|
if (typeof original !== "function") return;
|
|
player[key] = (time: number) => {
|
|
original.call(player, time);
|
|
studioWin.__hfStudioMotionApply?.();
|
|
};
|
|
};
|
|
wrapPlayerMethod("renderSeek");
|
|
wrapPlayerMethod("seek");
|
|
studioWin.__hfStudioMotionWrapped = true;
|
|
return true;
|
|
}
|