mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +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:
@@ -5,11 +5,16 @@ import {
|
||||
DEFAULT_CUSTOM_EASE_POINTS,
|
||||
GSAP_EASE_CONTROL_POINTS,
|
||||
CUSTOM_EASE_DATA_PATTERN,
|
||||
STUDIO_MOTION_ATTR,
|
||||
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
|
||||
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
|
||||
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
|
||||
type StudioCustomEaseControlPoints,
|
||||
type StudioGsapCustomEase,
|
||||
type StudioGsapMotion,
|
||||
type StudioGsapMotionPreset,
|
||||
type StudioGsapPresetMotionOptions,
|
||||
type StudioGsapMotionValues,
|
||||
type StudioMotionManifest,
|
||||
type StudioMotionTarget,
|
||||
} from "./studioMotionTypes";
|
||||
@@ -124,12 +129,10 @@ export function buildStudioGsapPresetMotion(
|
||||
|
||||
// ── Manifest parse/serialize ──
|
||||
|
||||
function parseMotionValues(
|
||||
value: unknown,
|
||||
): import("./studioMotionTypes").StudioGsapMotionValues | null {
|
||||
export function parseMotionValues(value: unknown): StudioGsapMotionValues | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const parsed: import("./studioMotionTypes").StudioGsapMotionValues = {};
|
||||
const parsed: StudioGsapMotionValues = {};
|
||||
for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"] as const) {
|
||||
const next = finiteNumber(record[key]);
|
||||
if (next != null) parsed[key] = next;
|
||||
@@ -297,3 +300,74 @@ export function getStudioMotionForSelection(
|
||||
): StudioGsapMotion | null {
|
||||
return manifest.motions.find((motion) => sameSelectionTarget(motion, selection)) ?? null;
|
||||
}
|
||||
|
||||
// ── HTML-attribute–backed motion storage ──
|
||||
|
||||
/** The JSON stored in the attribute omits kind/target/updatedAt — those are derived from context. */
|
||||
interface StudioMotionAttrPayload {
|
||||
start: number;
|
||||
duration: number;
|
||||
ease: string;
|
||||
customEase?: StudioGsapCustomEase;
|
||||
from: StudioGsapMotionValues;
|
||||
to: StudioGsapMotionValues;
|
||||
}
|
||||
|
||||
export function readStudioMotionFromElement(
|
||||
element: HTMLElement,
|
||||
): Omit<StudioGsapMotion, "kind" | "target" | "updatedAt"> | null {
|
||||
const json = element.getAttribute(STUDIO_MOTION_ATTR);
|
||||
if (!json || json === "true") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const start = finiteNumber(record.start);
|
||||
const duration = finiteNumber(record.duration);
|
||||
if (start == null || duration == null || start < 0 || duration <= 0) return null;
|
||||
const ease =
|
||||
typeof record.ease === "string" && record.ease.trim() ? record.ease.trim() : "none";
|
||||
const from = parseMotionValues(record.from);
|
||||
const to = parseMotionValues(record.to);
|
||||
if (!from || !to) return null;
|
||||
return { start, duration, ease, customEase: parseCustomEase(record.customEase), from, to };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStudioMotionToElement(
|
||||
element: HTMLElement,
|
||||
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
|
||||
): void {
|
||||
// Capture original styles before first write (only if not already captured)
|
||||
if (!element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)) {
|
||||
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);
|
||||
}
|
||||
const payload: StudioMotionAttrPayload = {
|
||||
start: motion.start,
|
||||
duration: motion.duration,
|
||||
ease: motion.ease,
|
||||
from: motion.from,
|
||||
to: motion.to,
|
||||
};
|
||||
if (motion.customEase) payload.customEase = motion.customEase;
|
||||
element.setAttribute(STUDIO_MOTION_ATTR, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function clearStudioMotionFromElement(
|
||||
element: HTMLElement,
|
||||
gsap?: { set?: (target: HTMLElement, vars: Record<string, unknown>) => void },
|
||||
): 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user