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:
Miguel Ángel
2026-05-15 21:45:57 +02:00
committed by GitHub
parent adeb92ecb7
commit 9b23ccf665
15 changed files with 1210 additions and 303 deletions
@@ -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 = () => {