Skip to content

Commit 9b23ccf

Browse files
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
1 parent adeb92e commit 9b23ccf

15 files changed

Lines changed: 1210 additions & 303 deletions

packages/core/src/studio-api/helpers/manualEditsRenderScript.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,14 @@ function studioPositionSeekReapplyRuntime(): void {
3030
const ROTATION_ATTR = "data-hf-studio-rotation";
3131
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
3232
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
33+
const MOTION_ATTR = "data-hf-studio-motion";
34+
const MOTION_TL_KEY = "studio-motion";
3335
const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped";
3436

3537
if (
3638
!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') &&
37-
!document.querySelector("[" + ROTATION_ATTR + '="true"]')
39+
!document.querySelector("[" + ROTATION_ATTR + '="true"]') &&
40+
!document.querySelector("[" + MOTION_ATTR + "]")
3841
)
3942
return;
4043

@@ -77,6 +80,117 @@ function studioPositionSeekReapplyRuntime(): void {
7780
return "calc(" + original + " + " + rotationValue + ")";
7881
};
7982

83+
let lastSeekTime = 0;
84+
let cachedMotionKey = "";
85+
86+
const finiteNum = (v: unknown): number | null =>
87+
typeof v === "number" && Number.isFinite(v) ? v : null;
88+
89+
const computeMotionKey = (motionEls: NodeListOf<Element>): string => {
90+
let key = "";
91+
for (let i = 0; i < motionEls.length; i++) {
92+
const json = (motionEls[i] as HTMLElement).getAttribute?.(MOTION_ATTR);
93+
if (json) key += (key ? "\n" : "") + json;
94+
}
95+
return key;
96+
};
97+
98+
const reapplyMotionTimeline = (): void => {
99+
const motionEls = document.querySelectorAll("[" + MOTION_ATTR + "]");
100+
if (motionEls.length === 0) {
101+
cachedMotionKey = "";
102+
return;
103+
}
104+
const win = window as Window & {
105+
gsap?: {
106+
timeline?: (opts: Record<string, unknown>) => Record<string, unknown>;
107+
set?: (el: HTMLElement, vars: Record<string, unknown>) => void;
108+
registerPlugin?: (plugin: unknown) => void;
109+
};
110+
CustomEase?: { create?: (id: string, data: string) => void };
111+
__timelines?: Record<string, Record<string, unknown>>;
112+
};
113+
const gsap = win.gsap;
114+
if (!gsap || typeof gsap.timeline !== "function") return;
115+
win.__timelines = win.__timelines || {};
116+
117+
// Cache the timeline keyed by the concatenated motion JSON strings.
118+
// On each seek, if the key hasn't changed, just seek the existing timeline
119+
// instead of rebuilding it (avoids kill+recreate on every frame).
120+
const motionKey = computeMotionKey(motionEls);
121+
const existing = win.__timelines[MOTION_TL_KEY];
122+
if (
123+
motionKey &&
124+
motionKey === cachedMotionKey &&
125+
existing &&
126+
typeof existing.totalTime === "function"
127+
) {
128+
(existing.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
129+
return;
130+
}
131+
132+
if (existing && typeof existing.kill === "function") (existing.kill as () => void)();
133+
const tl = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } });
134+
const fromTo = tl.fromTo as (
135+
el: HTMLElement,
136+
from: Record<string, unknown>,
137+
to: Record<string, unknown>,
138+
pos: number,
139+
) => void;
140+
if (typeof fromTo !== "function") return;
141+
let applied = 0;
142+
for (let i = 0; i < motionEls.length; i++) {
143+
const el = motionEls[i] as HTMLElement;
144+
if (!(el instanceof HTMLElement)) continue;
145+
const json = el.getAttribute(MOTION_ATTR);
146+
if (!json) continue;
147+
try {
148+
const m = JSON.parse(json) as Record<string, unknown>;
149+
const start = finiteNum(m.start);
150+
const duration = finiteNum(m.duration);
151+
if (start == null || duration == null || duration <= 0) continue;
152+
const ease = typeof m.ease === "string" ? m.ease : "none";
153+
const from = (m.from && typeof m.from === "object" ? m.from : {}) as Record<
154+
string,
155+
unknown
156+
>;
157+
const to = (m.to && typeof m.to === "object" ? m.to : {}) as Record<string, unknown>;
158+
const customEase = m.customEase as { id?: string; data?: string } | null | undefined;
159+
let resolvedEase = ease;
160+
if (customEase?.id && customEase?.data && win.CustomEase?.create) {
161+
try {
162+
gsap.registerPlugin?.(win.CustomEase);
163+
win.CustomEase.create(customEase.id, customEase.data);
164+
resolvedEase = customEase.id;
165+
} catch {
166+
/* use default ease */
167+
}
168+
}
169+
fromTo.call(
170+
tl,
171+
el,
172+
{ ...from },
173+
{ ...to, duration, ease: resolvedEase, overwrite: "auto", immediateRender: false },
174+
start,
175+
);
176+
applied += 1;
177+
} catch {
178+
/* malformed JSON — skip */
179+
}
180+
}
181+
if (applied === 0) {
182+
cachedMotionKey = "";
183+
if (typeof (tl as { kill?: () => void }).kill === "function")
184+
(tl as { kill: () => void }).kill();
185+
return;
186+
}
187+
cachedMotionKey = motionKey;
188+
win.__timelines[MOTION_TL_KEY] = tl;
189+
if (typeof tl.pause === "function") (tl.pause as () => void)();
190+
if (typeof tl.totalTime === "function")
191+
(tl.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
192+
};
193+
80194
const reapplyAll = (): void => {
81195
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
82196
for (let i = 0; i < offsetEls.length; i++) {
@@ -104,6 +218,7 @@ function studioPositionSeekReapplyRuntime(): void {
104218
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
105219
}
106220
}
221+
reapplyMotionTimeline();
107222
};
108223

109224
const runtimeWindow = window as Window & {
@@ -139,6 +254,7 @@ function studioPositionSeekReapplyRuntime(): void {
139254
return true;
140255
}
141256
const wrapped = function (this: unknown, time: number): unknown {
257+
lastSeekTime = typeof time === "number" && Number.isFinite(time) ? Math.max(0, time) : 0;
142258
const result = seek.call(this, time);
143259
reapplyAll();
144260
return result;

packages/producer/src/services/htmlCompiler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1000,7 +1000,11 @@ export async function compileForRender(
10001000
// GSAP overwrites the `translate` CSS property on every frame seek; this script
10011001
// re-asserts the CSS custom property var() form after each seek so dragged
10021002
// positions survive frame-by-frame rendering without a JSON sidecar.
1003-
const HF_POSITION_ATTRS = ['data-hf-studio-path-offset="true"', 'data-hf-studio-rotation="true"'];
1003+
const HF_POSITION_ATTRS = [
1004+
'data-hf-studio-path-offset="true"',
1005+
'data-hf-studio-rotation="true"',
1006+
'data-hf-studio-motion="',
1007+
];
10041008
const hasPositionEdits = HF_POSITION_ATTRS.some((attr) => htmlWithAssets.includes(attr));
10051009
const html = hasPositionEdits
10061010
? htmlWithAssets.replace(

packages/studio/src/App.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
STUDIO_INSPECTOR_PANELS_ENABLED,
2626
STUDIO_MOTION_PANEL_ENABLED,
2727
} from "./components/editor/manualEditingAvailability";
28-
import { getStudioMotionForSelection } from "./components/editor/studioMotion";
28+
import { readStudioMotionFromElement } from "./components/editor/studioMotion";
2929
import type { DomEditSelection } from "./components/editor/domEditing";
3030
import { AskAgentModal } from "./components/AskAgentModal";
3131
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
@@ -200,9 +200,6 @@ export function StudioApp() {
200200
showToast,
201201
refreshPreviewDocumentVersion,
202202
queueDomEditSave: manifestPersistence.queueDomEditSave,
203-
commitStudioMotionManifestOptimistically:
204-
manifestPersistence.commitStudioMotionManifestOptimistically,
205-
applyCurrentStudioMotionToPreview: manifestPersistence.applyCurrentStudioMotionToPreview,
206203
readProjectFile: fileManager.readProjectFile,
207204
writeProjectFile: fileManager.writeProjectFile,
208205
domEditSaveTimestampRef,
@@ -215,7 +212,6 @@ export function StudioApp() {
215212
refreshKey,
216213
rightPanelTab: panelLayout.rightPanelTab,
217214
applyStudioManualEditsToPreviewRef: manifestPersistence.applyStudioManualEditsToPreviewRef,
218-
applyStudioMotionToPreviewRef: manifestPersistence.applyStudioMotionToPreviewRef,
219215
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
220216
reloadPreview,
221217
setRefreshKey,
@@ -292,10 +288,7 @@ export function StudioApp() {
292288

293289
const selectedStudioMotion =
294290
STUDIO_INSPECTOR_PANELS_ENABLED && domEditSession.domEditSelection
295-
? getStudioMotionForSelection(
296-
manifestPersistence.studioMotionManifestRef.current,
297-
domEditSession.domEditSelection,
298-
)
291+
? readStudioMotionFromElement(domEditSession.domEditSelection.element)
299292
: null;
300293
const layersPanelActive =
301294
STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "layers";

packages/studio/src/components/StudioRightPanel.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
99
STUDIO_INSPECTOR_PANELS_ENABLED,
1010
STUDIO_MOTION_PANEL_ENABLED,
1111
} from "./editor/manualEditingAvailability";
12+
13+
/** Motion data without targeting metadata. */
14+
type StudioMotionData = Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">;
1215
import { useCallback } from "react";
1316
import { resolveDomEditSelection, type DomEditLayerItem } from "./editor/domEditing";
1417
import { useStudioContext } from "../contexts/StudioContext";
@@ -17,7 +20,7 @@ import { useFileManagerContext } from "../contexts/FileManagerContext";
1720
import { useDomEditContext } from "../contexts/DomEditContext";
1821

1922
export interface StudioRightPanelProps {
20-
selectedStudioMotion: StudioGsapMotion | null;
23+
selectedStudioMotion: StudioMotionData | null;
2124
designPanelActive: boolean;
2225
motionPanelActive: boolean;
2326
}

packages/studio/src/components/editor/MotionPanel.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ import {
2424
} from "./MotionPanelFields";
2525
import { EaseCurveEditor } from "./EaseCurveEditor";
2626

27+
/** Motion data without targeting metadata (kind/target/updatedAt are derived from context). */
28+
type StudioMotionData = Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">;
29+
2730
interface MotionPanelProps {
2831
element: DomEditSelection | null;
29-
motion: StudioGsapMotion | null;
32+
motion: StudioMotionData | null;
3033
onClearSelection: () => void;
31-
onSetMotion: (
32-
element: DomEditSelection,
33-
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
34-
) => void;
34+
onSetMotion: (element: DomEditSelection, motion: StudioMotionData) => void;
3535
onClearMotion: (element: DomEditSelection) => void;
3636
}
3737

@@ -43,19 +43,19 @@ const MOTION_PRESET_OPTIONS: Array<{ label: string; value: StudioGsapMotionPrese
4343

4444
const MOTION_DIRECTION_OPTIONS: StudioGsapMotionDirection[] = ["up", "down", "left", "right"];
4545

46-
function motionValueDistance(motion: StudioGsapMotion | null): number {
46+
function motionValueDistance(motion: StudioMotionData | null): number {
4747
if (!motion) return 32;
4848
return Math.max(Math.abs(motion.from.x ?? 0), Math.abs(motion.from.y ?? 0), 1);
4949
}
5050

51-
function inferMotionPreset(motion: StudioGsapMotion | null): StudioGsapMotionPreset {
51+
function inferMotionPreset(motion: StudioMotionData | null): StudioGsapMotionPreset {
5252
if (!motion) return "fade-up";
5353
if (motion.from.scale != null || motion.to.scale != null) return "pop";
5454
if (motion.from.x != null || motion.to.x != null) return "slide";
5555
return "fade-up";
5656
}
5757

58-
function inferMotionDirection(motion: StudioGsapMotion | null): StudioGsapMotionDirection {
58+
function inferMotionDirection(motion: StudioMotionData | null): StudioGsapMotionDirection {
5959
if (!motion) return "up";
6060
const x = motion.from.x ?? 0;
6161
const y = motion.from.y ?? 0;

packages/studio/src/components/editor/manualEdits.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export {
3030
clearStudioRotation,
3131
clearStudioBoxSize,
3232
reapplyPositionEditsAfterSeek,
33+
buildMotionPatches,
34+
buildClearMotionPatches,
3335
} from "./manualEditsDom";
3436

3537
export {

packages/studio/src/components/editor/manualEditsDom.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ import {
3131
STUDIO_ROTATION_TRANSFORM_ORIGIN,
3232
} from "./manualEditsTypes";
3333
import { roundRotationAngle } from "./manualEditsParsing";
34+
import {
35+
STUDIO_MOTION_ATTR,
36+
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
37+
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
38+
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
39+
} from "./studioMotionTypes";
40+
import { applyStudioMotionFromDom } from "./studioMotion";
3441

3542
/* ── Gesture tracking ─────────────────────────────────────────────── */
3643
let studioManualEditGestureId = 0;
@@ -755,6 +762,52 @@ export function buildClearRotationPatches(element: HTMLElement): PatchOperation[
755762
return ops;
756763
}
757764

765+
/* ── Motion HTML patch builders ──────────────────────────────────── */
766+
767+
export function buildMotionPatches(element: HTMLElement): PatchOperation[] {
768+
const motionJson = element.getAttribute(STUDIO_MOTION_ATTR);
769+
if (!motionJson) return [];
770+
const ops: PatchOperation[] = [
771+
{ type: "attribute", property: STUDIO_MOTION_ATTR, value: motionJson },
772+
];
773+
const origTransform = element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR);
774+
if (origTransform !== null) {
775+
ops.push({
776+
type: "attribute",
777+
property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
778+
value: origTransform,
779+
});
780+
}
781+
const origOpacity = element.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR);
782+
if (origOpacity !== null) {
783+
ops.push({
784+
type: "attribute",
785+
property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
786+
value: origOpacity,
787+
});
788+
}
789+
const origVisibility = element.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR);
790+
if (origVisibility !== null) {
791+
ops.push({
792+
type: "attribute",
793+
property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
794+
value: origVisibility,
795+
});
796+
}
797+
return ops;
798+
}
799+
800+
export function buildClearMotionPatches(_element: HTMLElement): PatchOperation[] {
801+
return [
802+
{ type: "attribute", property: STUDIO_MOTION_ATTR, value: null },
803+
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, value: null },
804+
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: null },
805+
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: null },
806+
];
807+
}
808+
809+
/* ── Seek reapply (position + motion) ────────────────────────────── */
810+
758811
export function reapplyPositionEditsAfterSeek(doc: Document): void {
759812
const htmlElement = doc.defaultView?.HTMLElement;
760813
if (!htmlElement) return;
@@ -793,4 +846,7 @@ export function reapplyPositionEditsAfterSeek(doc: Document): void {
793846
applyStudioRotation(el, { angle });
794847
}
795848
}
849+
850+
// Reapply DOM-backed motion timeline after seek
851+
applyStudioMotionFromDom(doc);
796852
}

0 commit comments

Comments
 (0)