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
+45 -33
View File
@@ -20,12 +20,14 @@ import {
buildClearPathOffsetPatches,
buildClearBoxSizePatches,
buildClearRotationPatches,
buildMotionPatches,
buildClearMotionPatches,
} from "../components/editor/manualEditsDom";
import {
removeStudioMotionForSelection,
writeStudioMotionToElement,
clearStudioMotionFromElement,
applyStudioMotionFromDom,
type StudioGsapMotion,
type StudioMotionManifest,
upsertStudioGsapMotion,
} from "../components/editor/studioMotion";
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
@@ -58,11 +60,6 @@ export interface UseDomEditCommitsParams {
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
showToast: (message: string, tone?: "error" | "info") => void;
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
commitStudioMotionManifestOptimistically: (
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
options: { label: string; coalesceKey: string },
) => void;
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
@@ -93,8 +90,6 @@ export function useDomEditCommits({
previewIframeRef,
showToast,
queueDomEditSave,
commitStudioMotionManifestOptimistically,
applyCurrentStudioMotionToPreview,
writeProjectFile,
domEditSaveTimestampRef,
editHistory,
@@ -306,43 +301,60 @@ export function useDomEditCommits({
[commitPositionPatchToHtml],
);
// ── Motion commits ──
// ── Motion commits (HTML-attributebacked) ──
const handleDomMotionCommit = useCallback(
(
selection: DomEditSelection,
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
) => {
commitStudioMotionManifestOptimistically(
(manifest) => upsertStudioGsapMotion(manifest, selection, motion),
{
label: "Set GSAP motion",
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
},
);
// 1. Write motion data as JSON attribute on the element
writeStudioMotionToElement(selection.element, motion);
// 2. Apply the GSAP timeline from DOM attributes
let doc: Document | null = null;
try {
doc = previewIframeRef.current?.contentDocument ?? null;
} catch {
// cross-origin guard
}
if (doc) applyStudioMotionFromDom(doc);
// 3. Build patches and persist to HTML
const patches = buildMotionPatches(selection.element);
commitPositionPatchToHtml(selection, patches, {
label: "Set GSAP motion",
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
});
refreshDomEditSelectionFromPreview(selection);
},
[commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
);
const handleDomMotionClear = useCallback(
(selection: DomEditSelection) => {
commitStudioMotionManifestOptimistically(
(manifest) => removeStudioMotionForSelection(manifest, selection),
{
label: "Clear GSAP motion",
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
},
);
applyCurrentStudioMotionToPreview(previewIframeRef.current);
const clearPatches = buildClearMotionPatches(selection.element);
// Get gsap from the preview window for proper cleanup
let gsap: { set?: (target: HTMLElement, vars: Record<string, unknown>) => void } | undefined;
try {
gsap = (previewIframeRef.current?.contentWindow as { gsap?: typeof gsap })?.gsap;
} catch {
// cross-origin guard
}
clearStudioMotionFromElement(selection.element, gsap);
let doc: Document | null = null;
try {
doc = previewIframeRef.current?.contentDocument ?? null;
} catch {
// cross-origin guard
}
if (doc) applyStudioMotionFromDom(doc);
commitPositionPatchToHtml(selection, clearPatches, {
label: "Clear GSAP motion",
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
skipRefresh: false,
});
refreshDomEditSelectionFromPreview(selection);
},
[
applyCurrentStudioMotionToPreview,
commitStudioMotionManifestOptimistically,
refreshDomEditSelectionFromPreview,
previewIframeRef,
],
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
);
const handleDomEditElementDelete = useCallback(