mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -0,0 +1,445 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Window } from "happy-dom";
|
||||
import {
|
||||
readStudioMotionFromElement,
|
||||
writeStudioMotionToElement,
|
||||
clearStudioMotionFromElement,
|
||||
} from "./studioMotionOps";
|
||||
import {
|
||||
STUDIO_MOTION_ATTR,
|
||||
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
|
||||
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
|
||||
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
|
||||
} from "./studioMotionTypes";
|
||||
import { buildMotionPatches, buildClearMotionPatches } from "./manualEditsDom";
|
||||
import { applyPatchByTarget, readAttributeByTarget } from "../../utils/sourcePatcher";
|
||||
|
||||
function createElement(markup: string): HTMLElement {
|
||||
const window = new Window();
|
||||
window.document.body.innerHTML = markup;
|
||||
return window.document.body.firstElementChild as HTMLElement;
|
||||
}
|
||||
|
||||
// ── readStudioMotionFromElement semantics ──
|
||||
|
||||
describe("readStudioMotionFromElement", () => {
|
||||
it("returns null for element with no attribute", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
expect(readStudioMotionFromElement(el)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for legacy marker value 'true'", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
el.setAttribute(STUDIO_MOTION_ATTR, "true");
|
||||
expect(readStudioMotionFromElement(el)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
el.setAttribute(STUDIO_MOTION_ATTR, "{not valid json");
|
||||
expect(readStudioMotionFromElement(el)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object JSON", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
el.setAttribute(STUDIO_MOTION_ATTR, '"just a string"');
|
||||
expect(readStudioMotionFromElement(el)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when start < 0", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
el.setAttribute(
|
||||
STUDIO_MOTION_ATTR,
|
||||
JSON.stringify({
|
||||
start: -0.5,
|
||||
duration: 1,
|
||||
ease: "none",
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 },
|
||||
}),
|
||||
);
|
||||
expect(readStudioMotionFromElement(el)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when duration <= 0", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
el.setAttribute(
|
||||
STUDIO_MOTION_ATTR,
|
||||
JSON.stringify({
|
||||
start: 0,
|
||||
duration: 0,
|
||||
ease: "none",
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 },
|
||||
}),
|
||||
);
|
||||
expect(readStudioMotionFromElement(el)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when duration is negative", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
el.setAttribute(
|
||||
STUDIO_MOTION_ATTR,
|
||||
JSON.stringify({
|
||||
start: 0,
|
||||
duration: -1,
|
||||
ease: "none",
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 },
|
||||
}),
|
||||
);
|
||||
expect(readStudioMotionFromElement(el)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when from is missing", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
el.setAttribute(
|
||||
STUDIO_MOTION_ATTR,
|
||||
JSON.stringify({
|
||||
start: 0,
|
||||
duration: 1,
|
||||
ease: "none",
|
||||
to: { opacity: 1 },
|
||||
}),
|
||||
);
|
||||
expect(readStudioMotionFromElement(el)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when to is missing", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
el.setAttribute(
|
||||
STUDIO_MOTION_ATTR,
|
||||
JSON.stringify({
|
||||
start: 0,
|
||||
duration: 1,
|
||||
ease: "none",
|
||||
from: { opacity: 0 },
|
||||
}),
|
||||
);
|
||||
expect(readStudioMotionFromElement(el)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when from/to have no recognized motion properties", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
el.setAttribute(
|
||||
STUDIO_MOTION_ATTR,
|
||||
JSON.stringify({
|
||||
start: 0,
|
||||
duration: 1,
|
||||
ease: "none",
|
||||
from: { color: "red" },
|
||||
to: { color: "blue" },
|
||||
}),
|
||||
);
|
||||
expect(readStudioMotionFromElement(el)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns parsed motion for valid JSON", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
const motion = {
|
||||
start: 0.5,
|
||||
duration: 1,
|
||||
ease: "power3.out",
|
||||
from: { opacity: 0, y: 40 },
|
||||
to: { opacity: 1, y: 0 },
|
||||
};
|
||||
el.setAttribute(STUDIO_MOTION_ATTR, JSON.stringify(motion));
|
||||
|
||||
const result = readStudioMotionFromElement(el);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual({
|
||||
start: 0.5,
|
||||
duration: 1,
|
||||
ease: "power3.out",
|
||||
customEase: undefined,
|
||||
from: { opacity: 0, y: 40 },
|
||||
to: { opacity: 1, y: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns parsed motion with customEase", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
const motion = {
|
||||
start: 0,
|
||||
duration: 0.6,
|
||||
ease: "studio-custom",
|
||||
customEase: { id: "studio-custom", data: "M0,0 C0.2,0.9 0.28,1 1,1" },
|
||||
from: { scale: 0.88, autoAlpha: 0 },
|
||||
to: { scale: 1, autoAlpha: 1 },
|
||||
};
|
||||
el.setAttribute(STUDIO_MOTION_ATTR, JSON.stringify(motion));
|
||||
|
||||
const result = readStudioMotionFromElement(el);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.customEase).toEqual({ id: "studio-custom", data: "M0,0 C0.2,0.9 0.28,1 1,1" });
|
||||
});
|
||||
|
||||
it("defaults ease to 'none' when ease is empty string", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
el.setAttribute(
|
||||
STUDIO_MOTION_ATTR,
|
||||
JSON.stringify({
|
||||
start: 0,
|
||||
duration: 1,
|
||||
ease: "",
|
||||
from: { y: 40 },
|
||||
to: { y: 0 },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = readStudioMotionFromElement(el);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ease).toBe("none");
|
||||
});
|
||||
|
||||
it("accepts start = 0 as valid", () => {
|
||||
const el = createElement(`<div id="test"></div>`);
|
||||
el.setAttribute(
|
||||
STUDIO_MOTION_ATTR,
|
||||
JSON.stringify({
|
||||
start: 0,
|
||||
duration: 0.5,
|
||||
ease: "none",
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = readStudioMotionFromElement(el);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.start).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── writeStudioMotionToElement / readStudioMotionFromElement round-trip ──
|
||||
|
||||
describe("write → read round-trip via DOM", () => {
|
||||
it("round-trips motion through write and read", () => {
|
||||
const el = createElement(`<div id="hero" style="transform: rotate(5deg); opacity: 0.8"></div>`);
|
||||
const motion = {
|
||||
start: 0.5,
|
||||
duration: 1,
|
||||
ease: "power3.out",
|
||||
from: { opacity: 0, y: 40 },
|
||||
to: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
writeStudioMotionToElement(el, motion);
|
||||
const result = readStudioMotionFromElement(el);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.start).toBe(0.5);
|
||||
expect(result!.duration).toBe(1);
|
||||
expect(result!.ease).toBe("power3.out");
|
||||
expect(result!.from).toEqual({ opacity: 0, y: 40 });
|
||||
expect(result!.to).toEqual({ opacity: 1, y: 0 });
|
||||
});
|
||||
|
||||
it("captures original styles on first write", () => {
|
||||
const el = createElement(
|
||||
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: hidden"></div>`,
|
||||
);
|
||||
const motion = {
|
||||
start: 0,
|
||||
duration: 0.6,
|
||||
ease: "none",
|
||||
from: { autoAlpha: 0 },
|
||||
to: { autoAlpha: 1 },
|
||||
};
|
||||
|
||||
writeStudioMotionToElement(el, motion);
|
||||
|
||||
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe("rotate(5deg)");
|
||||
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe("0.8");
|
||||
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR)).toBe("hidden");
|
||||
});
|
||||
|
||||
it("does not overwrite original styles on subsequent writes", () => {
|
||||
const el = createElement(
|
||||
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
|
||||
);
|
||||
const first = { start: 0, duration: 0.6, ease: "none", from: { y: 40 }, to: { y: 0 } };
|
||||
const second = { start: 0.2, duration: 1, ease: "power2.out", from: { y: 60 }, to: { y: 0 } };
|
||||
|
||||
writeStudioMotionToElement(el, first);
|
||||
// Simulate GSAP modifying styles
|
||||
el.style.transform = "matrix(1, 0, 0, 1, 0, 20)";
|
||||
el.style.opacity = "0.3";
|
||||
|
||||
writeStudioMotionToElement(el, second);
|
||||
|
||||
// Original capture should be preserved from the first write
|
||||
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe("rotate(5deg)");
|
||||
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe("0.8");
|
||||
});
|
||||
});
|
||||
|
||||
// ── clearStudioMotionFromElement ──
|
||||
|
||||
describe("clearStudioMotionFromElement", () => {
|
||||
it("removes all four motion-related attributes", () => {
|
||||
const el = createElement(
|
||||
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
|
||||
);
|
||||
const motion = {
|
||||
start: 0,
|
||||
duration: 0.6,
|
||||
ease: "none",
|
||||
from: { autoAlpha: 0 },
|
||||
to: { autoAlpha: 1 },
|
||||
};
|
||||
writeStudioMotionToElement(el, motion);
|
||||
|
||||
expect(el.hasAttribute(STUDIO_MOTION_ATTR)).toBe(true);
|
||||
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe(true);
|
||||
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe(true);
|
||||
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR)).toBe(true);
|
||||
|
||||
clearStudioMotionFromElement(el);
|
||||
|
||||
expect(el.hasAttribute(STUDIO_MOTION_ATTR)).toBe(false);
|
||||
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe(false);
|
||||
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe(false);
|
||||
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR)).toBe(false);
|
||||
});
|
||||
|
||||
it("restores original inline styles after clearing", () => {
|
||||
const el = createElement(
|
||||
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: hidden"></div>`,
|
||||
);
|
||||
writeStudioMotionToElement(el, {
|
||||
start: 0,
|
||||
duration: 0.6,
|
||||
ease: "none",
|
||||
from: { autoAlpha: 0, y: 32 },
|
||||
to: { autoAlpha: 1, y: 0 },
|
||||
});
|
||||
|
||||
// Simulate GSAP overwriting styles
|
||||
el.style.transform = "matrix(1, 0, 0, 1, 0, 16)";
|
||||
el.style.opacity = "0.5";
|
||||
el.style.visibility = "visible";
|
||||
|
||||
clearStudioMotionFromElement(el);
|
||||
|
||||
expect(el.style.transform).toBe("rotate(5deg)");
|
||||
expect(el.style.opacity).toBe("0.8");
|
||||
expect(el.style.visibility).toBe("hidden");
|
||||
});
|
||||
|
||||
it("is a no-op when element has no motion attribute", () => {
|
||||
const el = createElement(`<div id="hero" style="opacity: 1"></div>`);
|
||||
|
||||
clearStudioMotionFromElement(el);
|
||||
|
||||
expect(el.style.opacity).toBe("1");
|
||||
expect(el.hasAttribute(STUDIO_MOTION_ATTR)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildMotionPatches / buildClearMotionPatches ──
|
||||
|
||||
describe("buildMotionPatches", () => {
|
||||
it("produces patches for all motion-related attributes present on the element", () => {
|
||||
const el = createElement(
|
||||
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
|
||||
);
|
||||
const motion = {
|
||||
start: 0.5,
|
||||
duration: 1,
|
||||
ease: "power3.out",
|
||||
from: { opacity: 0, y: 40 },
|
||||
to: { opacity: 1, y: 0 },
|
||||
};
|
||||
writeStudioMotionToElement(el, motion);
|
||||
|
||||
const patches = buildMotionPatches(el);
|
||||
|
||||
// Should have at least the motion attribute patch
|
||||
const motionPatch = patches.find((p) => p.property === STUDIO_MOTION_ATTR);
|
||||
expect(motionPatch).toBeDefined();
|
||||
expect(motionPatch!.type).toBe("attribute");
|
||||
expect(JSON.parse(motionPatch!.value!)).toMatchObject(motion);
|
||||
|
||||
// Should include original style capture patches
|
||||
expect(patches.find((p) => p.property === STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBeDefined();
|
||||
expect(patches.find((p) => p.property === STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBeDefined();
|
||||
expect(
|
||||
patches.find((p) => p.property === STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty when element has no motion attribute", () => {
|
||||
const el = createElement(`<div id="hero"></div>`);
|
||||
expect(buildMotionPatches(el)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClearMotionPatches round-trip", () => {
|
||||
it("applying clear patches removes all four motion attributes from HTML", () => {
|
||||
const el = createElement(
|
||||
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
|
||||
);
|
||||
writeStudioMotionToElement(el, {
|
||||
start: 0,
|
||||
duration: 0.6,
|
||||
ease: "power2.out",
|
||||
from: { autoAlpha: 0, y: 32 },
|
||||
to: { autoAlpha: 1, y: 0 },
|
||||
});
|
||||
|
||||
// First, apply the motion patches to an HTML string
|
||||
const motionPatches = buildMotionPatches(el);
|
||||
let html = `<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`;
|
||||
for (const patch of motionPatches) {
|
||||
html = applyPatchByTarget(html, { id: "hero" }, patch);
|
||||
}
|
||||
|
||||
// Verify all four attributes are present
|
||||
expect(readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ATTR)).toBeDefined();
|
||||
expect(
|
||||
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_OPACITY_ATTR),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR),
|
||||
).toBeDefined();
|
||||
|
||||
// Now apply clear patches
|
||||
const clearPatches = buildClearMotionPatches(el);
|
||||
for (const patch of clearPatches) {
|
||||
html = applyPatchByTarget(html, { id: "hero" }, patch);
|
||||
}
|
||||
|
||||
// All four should be gone
|
||||
expect(readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ATTR)).toBeUndefined();
|
||||
expect(
|
||||
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_OPACITY_ATTR),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clear patches produce exactly four null-value attribute operations", () => {
|
||||
const el = createElement(`<div id="hero"></div>`);
|
||||
const clearPatches = buildClearMotionPatches(el);
|
||||
|
||||
expect(clearPatches).toHaveLength(4);
|
||||
for (const patch of clearPatches) {
|
||||
expect(patch.type).toBe("attribute");
|
||||
expect(patch.value).toBeNull();
|
||||
}
|
||||
|
||||
const properties = clearPatches.map((p) => p.property);
|
||||
expect(properties).toContain(STUDIO_MOTION_ATTR);
|
||||
expect(properties).toContain(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR);
|
||||
expect(properties).toContain(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR);
|
||||
expect(properties).toContain(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user