Skip to content

Commit 793c25b

Browse files
committed
feat(studio): html-backed motion panel — persist GSAP motion to element attributes
Re-architects the motion panel to store GSAP motion data as a JSON data attribute (data-hf-studio-motion) on each element instead of a .hyperframes/studio-motion.json sidecar file. Follows the same pattern as position/resize/rotation edits: write to DOM, build patches, persist to HTML source via commitPositionPatchToHtml. Render pipeline: the studioPositionSeekReapplyRuntime now queries [data-hf-studio-motion] elements after each seek, parses their JSON, builds a GSAP timeline, and seeks it to the current frame time. Studio preview: motion reapply is integrated into the manual edits seek hook (reapplyPositionEditsAfterSeek). useManifestPersistence is slimmed to only handle save queue and seek hooks.
1 parent e8e2e81 commit 793c25b

12 files changed

Lines changed: 414 additions & 295 deletions

File tree

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

Lines changed: 87 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,87 @@ function studioPositionSeekReapplyRuntime(): void {
7780
return "calc(" + original + " + " + rotationValue + ")";
7881
};
7982

83+
let lastSeekTime = 0;
84+
85+
const finiteNum = (v: unknown): number | null =>
86+
typeof v === "number" && Number.isFinite(v) ? v : null;
87+
88+
const reapplyMotionTimeline = (): void => {
89+
const motionEls = document.querySelectorAll("[" + MOTION_ATTR + "]");
90+
if (motionEls.length === 0) return;
91+
const win = window as Window & {
92+
gsap?: {
93+
timeline?: (opts: Record<string, unknown>) => Record<string, unknown>;
94+
set?: (el: HTMLElement, vars: Record<string, unknown>) => void;
95+
registerPlugin?: (plugin: unknown) => void;
96+
};
97+
CustomEase?: { create?: (id: string, data: string) => void };
98+
__timelines?: Record<string, Record<string, unknown>>;
99+
};
100+
const gsap = win.gsap;
101+
if (!gsap || typeof gsap.timeline !== "function") return;
102+
win.__timelines = win.__timelines || {};
103+
const existing = win.__timelines[MOTION_TL_KEY];
104+
if (existing && typeof existing.kill === "function") (existing.kill as () => void)();
105+
const tl = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } });
106+
const fromTo = tl.fromTo as (
107+
el: HTMLElement,
108+
from: Record<string, unknown>,
109+
to: Record<string, unknown>,
110+
pos: number,
111+
) => void;
112+
if (typeof fromTo !== "function") return;
113+
let applied = 0;
114+
for (let i = 0; i < motionEls.length; i++) {
115+
const el = motionEls[i] as HTMLElement;
116+
if (!(el instanceof HTMLElement)) continue;
117+
const json = el.getAttribute(MOTION_ATTR);
118+
if (!json) continue;
119+
try {
120+
const m = JSON.parse(json) as Record<string, unknown>;
121+
const start = finiteNum(m.start);
122+
const duration = finiteNum(m.duration);
123+
if (start == null || duration == null || duration <= 0) continue;
124+
const ease = typeof m.ease === "string" ? m.ease : "none";
125+
const from = (m.from && typeof m.from === "object" ? m.from : {}) as Record<
126+
string,
127+
unknown
128+
>;
129+
const to = (m.to && typeof m.to === "object" ? m.to : {}) as Record<string, unknown>;
130+
const customEase = m.customEase as { id?: string; data?: string } | null | undefined;
131+
let resolvedEase = ease;
132+
if (customEase?.id && customEase?.data && win.CustomEase?.create) {
133+
try {
134+
gsap.registerPlugin?.(win.CustomEase);
135+
win.CustomEase.create(customEase.id, customEase.data);
136+
resolvedEase = customEase.id;
137+
} catch {
138+
/* use default ease */
139+
}
140+
}
141+
fromTo.call(
142+
tl,
143+
el,
144+
{ ...from },
145+
{ ...to, duration, ease: resolvedEase, overwrite: "auto", immediateRender: false },
146+
start,
147+
);
148+
applied += 1;
149+
} catch {
150+
/* malformed JSON — skip */
151+
}
152+
}
153+
if (applied === 0) {
154+
if (typeof (tl as { kill?: () => void }).kill === "function")
155+
(tl as { kill: () => void }).kill();
156+
return;
157+
}
158+
win.__timelines[MOTION_TL_KEY] = tl;
159+
if (typeof tl.pause === "function") (tl.pause as () => void)();
160+
if (typeof tl.totalTime === "function")
161+
(tl.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
162+
};
163+
80164
const reapplyAll = (): void => {
81165
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
82166
for (let i = 0; i < offsetEls.length; i++) {
@@ -104,6 +188,7 @@ function studioPositionSeekReapplyRuntime(): void {
104188
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
105189
}
106190
}
191+
reapplyMotionTimeline();
107192
};
108193

109194
const runtimeWindow = window as Window & {
@@ -139,6 +224,7 @@ function studioPositionSeekReapplyRuntime(): void {
139224
return true;
140225
}
141226
const wrapped = function (this: unknown, time: number): unknown {
227+
lastSeekTime = typeof time === "number" && Number.isFinite(time) ? Math.max(0, time) : 0;
142228
const result = seek.call(this, time);
143229
reapplyAll();
144230
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)