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(
+2 -23
View File
@@ -2,7 +2,6 @@ import { useEffect } from "react";
import type { TimelineElement } from "../player";
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
import { findElementForSelection } from "../components/editor/domEditing";
import type { StudioMotionManifest } from "../components/editor/studioMotion";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
import type { RightPanelTab } from "../utils/studioHelpers";
@@ -36,11 +35,6 @@ export interface UseDomEditSessionParams {
showToast: (message: string, tone?: "error" | "info") => void;
refreshPreviewDocumentVersion: () => void;
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
commitStudioMotionManifestOptimistically: (
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
options: { label: string; coalesceKey: string },
) => void;
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
@@ -55,9 +49,6 @@ export interface UseDomEditSessionParams {
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
(iframe: HTMLIFrameElement) => Promise<void>
>;
applyStudioMotionToPreviewRef: React.MutableRefObject<
(iframe: HTMLIFrameElement) => Promise<void>
>;
syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
reloadPreview: () => void;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
@@ -81,8 +72,6 @@ export function useDomEditSession({
showToast,
refreshPreviewDocumentVersion,
queueDomEditSave,
commitStudioMotionManifestOptimistically,
applyCurrentStudioMotionToPreview,
readProjectFile: _readProjectFile,
writeProjectFile,
domEditSaveTimestampRef,
@@ -95,7 +84,6 @@ export function useDomEditSession({
refreshKey,
rightPanelTab,
applyStudioManualEditsToPreviewRef,
applyStudioMotionToPreviewRef,
syncPreviewHistoryHotkey,
reloadPreview,
setRefreshKey: _setRefreshKey,
@@ -200,8 +188,6 @@ export function useDomEditSession({
previewIframeRef,
showToast,
queueDomEditSave,
commitStudioMotionManifestOptimistically,
applyCurrentStudioMotionToPreview,
writeProjectFile,
domEditSaveTimestampRef,
editHistory,
@@ -249,19 +235,13 @@ export function useDomEditSession({
};
syncPreviewHistoryHotkey(previewIframe);
void (async () => {
await applyStudioManualEditsToPreviewRef.current(previewIframe);
await applyStudioMotionToPreviewRef.current(previewIframe);
})();
void applyStudioManualEditsToPreviewRef.current(previewIframe);
syncSelectionFromDocument();
refreshPreviewDocumentVersion();
const handleLoad = () => {
syncPreviewHistoryHotkey(previewIframe);
void (async () => {
await applyStudioManualEditsToPreviewRef.current(previewIframe);
await applyStudioMotionToPreviewRef.current(previewIframe);
})();
void applyStudioManualEditsToPreviewRef.current(previewIframe);
syncSelectionFromDocument();
refreshPreviewDocumentVersion();
};
@@ -280,7 +260,6 @@ export function useDomEditSession({
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
applyStudioManualEditsToPreviewRef,
applyStudioMotionToPreviewRef,
]);
return {
@@ -1,21 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useRef } from "react";
import { useMountEffect } from "./useMountEffect";
import {
installStudioManualEditSeekReapply,
reapplyPositionEditsAfterSeek,
readStudioFileChangePath,
} from "../components/editor/manualEdits";
import {
STUDIO_MOTION_PATH,
applyStudioMotionManifest,
emptyStudioMotionManifest,
installStudioMotionSeekReapply,
isStudioMotionManifestPath,
parseStudioMotionManifest,
serializeStudioMotionManifest,
type StudioMotionManifest,
} from "../components/editor/studioMotion";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import type { EditHistoryKind } from "../utils/editHistory";
// ── Types ──
@@ -46,35 +36,24 @@ interface UseManifestPersistenceParams {
export function useManifestPersistence({
projectId,
showToast,
showToast: _showToast,
readOptionalProjectFile: _readOptionalProjectFile,
writeProjectFile,
recordEdit,
writeProjectFile: _writeProjectFile,
recordEdit: _recordEdit,
previewIframeRef,
activeCompPathRef,
activeCompPathRef: _activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview,
}: UseManifestPersistenceParams) {
void _readOptionalProjectFile;
void _showToast;
void _recordEdit;
void _activeCompPathRef;
const [, setStudioMotionRevision] = useState(0);
const domTextCommitVersionRef = useRef(0);
const domEditSaveQueueRef = useRef(Promise.resolve());
const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
const studioMotionRevisionRef = useRef(0);
const applyStudioManualEditsToPreviewRef = useRef<
(
iframe?: HTMLIFrameElement | null,
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
) => Promise<void>
(iframe?: HTMLIFrameElement | null) => Promise<void>
>(async () => {});
const applyStudioMotionToPreviewRef = useRef<
(
iframe?: HTMLIFrameElement | null,
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
) => Promise<void>
>(async () => {});
const motionBootstrappedRef = useRef(false);
// Keep a ref to the latest projectId so async save callbacks always read the
// current value, even when the callback was captured in a stale closure.
@@ -96,7 +75,8 @@ export function useManifestPersistence({
await domEditSaveQueueRef.current.catch(() => undefined);
}, []);
// ── Apply manual edits (HTML-baked — just install seek hooks) ──
// ── Apply manual edits (HTML-baked — install seek hooks) ──
// reapplyPositionEditsAfterSeek now also handles motion reapply from DOM attributes.
const applyCurrentStudioManualEditsToPreview = useCallback(
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
@@ -143,199 +123,47 @@ export function useManifestPersistence({
);
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
// ── Apply motion ──
const applyCurrentStudioMotionToPreview = useCallback(
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
if (!iframe) return;
let doc: Document | null = null;
try {
doc = iframe.contentDocument;
} catch {
return;
}
if (!doc) return;
const previewDoc = doc;
const applyManifest = () => {
applyStudioMotionManifest(
previewDoc,
studioMotionManifestRef.current,
activeCompPathRef.current,
);
};
const applyAndInstallSeekHooks = () => {
applyManifest();
if (iframe.contentWindow) {
installStudioMotionSeekReapply(iframe.contentWindow, applyManifest);
}
};
const win = iframe.contentWindow;
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
win?.setTimeout?.(applyAndInstallSeekHooks, 120);
},
[activeCompPathRef, previewIframeRef],
);
const applyStudioMotionToPreview = useCallback(
async (
iframe: HTMLIFrameElement | null = previewIframeRef.current,
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
) => {
const needsBootstrap = !motionBootstrappedRef.current;
if (needsBootstrap) motionBootstrappedRef.current = true;
const readFromDiskFirst = Boolean(
options?.forceFromDisk || options?.readFromDiskFirst || needsBootstrap,
);
if (!readFromDiskFirst) {
applyCurrentStudioMotionToPreview(iframe);
return;
}
const readRevision = studioMotionRevisionRef.current;
let content: string;
try {
content = await _readOptionalProjectFile(STUDIO_MOTION_PATH);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to read motion manifest";
showToast(message);
applyCurrentStudioMotionToPreview(iframe);
return;
}
if (options?.forceFromDisk || readRevision === studioMotionRevisionRef.current) {
studioMotionManifestRef.current = parseStudioMotionManifest(content);
if (options?.forceFromDisk) studioMotionRevisionRef.current += 1;
setStudioMotionRevision((revision) => revision + 1);
}
applyCurrentStudioMotionToPreview(iframe);
},
[applyCurrentStudioMotionToPreview, previewIframeRef, _readOptionalProjectFile, showToast],
);
applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
// ── Optimistic motion commit ──
const commitStudioMotionManifestOptimistically = useCallback(
(
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
options: { label: string; coalesceKey: string },
) => {
const previousManifest = studioMotionManifestRef.current;
const nextManifest = updateManifest(previousManifest);
const previousContent = serializeStudioMotionManifest(previousManifest);
const nextContent = serializeStudioMotionManifest(nextManifest);
if (nextContent === previousContent) {
return;
}
const revision = studioMotionRevisionRef.current + 1;
studioMotionRevisionRef.current = revision;
studioMotionManifestRef.current = nextManifest;
setStudioMotionRevision((current) => current + 1);
applyCurrentStudioMotionToPreview(previewIframeRef.current);
const save = async () => {
const originalContent = await _readOptionalProjectFile(STUDIO_MOTION_PATH);
const diskManifest = parseStudioMotionManifest(originalContent);
const nextDiskManifest = updateManifest(diskManifest);
const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
if (nextDiskContent === originalContent) {
return;
}
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: options.label,
kind: "motion",
coalesceKey: options.coalesceKey,
files: { [STUDIO_MOTION_PATH]: nextDiskContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
domEditSaveTimestampRef.current = Date.now();
if (studioMotionRevisionRef.current === revision) {
studioMotionManifestRef.current = nextDiskManifest;
setStudioMotionRevision((current) => current + 1);
applyCurrentStudioMotionToPreview(previewIframeRef.current);
}
};
void queueDomEditSave(save).catch((error) => {
if (studioMotionRevisionRef.current === revision) {
studioMotionRevisionRef.current += 1;
studioMotionManifestRef.current = previousManifest;
setStudioMotionRevision((current) => current + 1);
applyCurrentStudioMotionToPreview(previewIframeRef.current);
}
const message = error instanceof Error ? error.message : "Failed to save motion edit";
showToast(message);
});
},
[
applyCurrentStudioMotionToPreview,
recordEdit,
queueDomEditSave,
_readOptionalProjectFile,
showToast,
writeProjectFile,
previewIframeRef,
domEditSaveTimestampRef,
],
);
// ── Sync preview after undo/redo ──
const syncHistoryPreviewAfterApply = useCallback(
async (paths: string[] | undefined) => {
const changedPaths = paths ?? [];
const motionManifestOnly =
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
if (motionManifestOnly) {
await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
return;
}
// Reload via refreshKey so NLELayout saves seek position before the iframe reloads.
async (_paths: string[] | undefined) => {
// Motion data is now stored in HTML attributes — any undo/redo that touches HTML
// files triggers a full reload which picks up the changes automatically.
reloadPreview();
},
[applyStudioMotionToPreview, previewIframeRef, reloadPreview],
[reloadPreview],
);
// ── Reset manifests when project changes ──
const projectTrackerRef = useRef<string | null>(projectId);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
const previousProjectId = projectTrackerRef.current;
projectTrackerRef.current = projectId;
if (!previousProjectId || previousProjectId === projectId) return;
studioMotionManifestRef.current = emptyStudioMotionManifest();
studioMotionRevisionRef.current += 1;
setStudioMotionRevision((revision) => revision + 1);
motionBootstrappedRef.current = false;
}, [projectId]);
// ── Migrate legacy studio-motion.json ──
// Projects that used the old JSON-file approach may still have a populated
// `.hyperframes/studio-motion.json`. The studio no longer reads from it, but
// the legacy render-script injection in `preview.ts` / `vite.studioMotion.ts`
// could still fire alongside the new seek-reapply runtime. Empty the file so
// the legacy codepath no-ops.
useMountEffect(() => {
_readOptionalProjectFile(STUDIO_MOTION_PATH)
.then((content) => {
if (!content) return;
try {
const parsed = JSON.parse(content) as { motions?: unknown[] };
if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return;
} catch {
return;
}
return _writeProjectFile(STUDIO_MOTION_PATH, JSON.stringify({ version: 1, motions: [] }));
})
.catch(() => {
/* best-effort migration — ignore failures */
});
});
// ── Listen for external file changes (HMR / SSE) ──
useMountEffect(() => {
const handler = (payload?: unknown) => {
const changedPath = readStudioFileChangePath(payload);
if (!changedPath) return;
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
if (isStudioMotionManifestPath(changedPath)) {
if (!recentDomEditSave) {
void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
forceFromDisk: true,
});
}
return;
}
// Non-motion external file change — reload unless it's an echo of our own save.
// External file change — reload unless it's an echo of our own save.
if (!recentDomEditSave) {
reloadPreview();
}
@@ -353,17 +181,11 @@ export function useManifestPersistence({
return {
domTextCommitVersionRef,
domEditSaveQueueRef,
studioMotionManifestRef,
studioMotionRevisionRef,
applyStudioManualEditsToPreviewRef,
applyStudioMotionToPreviewRef,
queueDomEditSave,
waitForPendingDomEditSaves,
applyCurrentStudioManualEditsToPreview,
applyStudioManualEditsToPreview,
applyCurrentStudioMotionToPreview,
applyStudioMotionToPreview,
commitStudioMotionManifestOptimistically,
syncHistoryPreviewAfterApply,
};
}