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
@@ -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-attributebacked 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);
}