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,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;