mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(studio): keyframe system — parser, runtime, timeline UI, design panel, gesture recording (#1311)
* feat(studio): runtime hooks — global time compiler + keyframe runtime Add the runtime bridge layer: global time compilation (tween % → clip %), soft reload after mutations, runtime keyframe preview, and keyframe commit helper. * feat(studio): runtime hooks — global time compiler + keyframe runtime Add the runtime bridge layer: global time compilation (tween % → clip %), soft reload after mutations, runtime keyframe preview, and keyframe commit helper. * feat(studio): keyframe cache + commit hooks Add hooks for keyframe cache population (tween → clip-relative %), mutation dispatch, keyframe snapping, and audio beat detection. * feat(studio): timeline UI — dopesheet diamonds + keyboard nav Add dopesheet strip with diamond keyframe indicators, timeline property rows, keyboard navigation (J/Shift+J/Delete/K), and feature gate (STUDIO_KEYFRAMES_ENABLED defaults to false). * feat(studio): design panel — arc controls + ease curve + stagger Add arc path controls (curviness slider, auto-rotate), motion path SVG overlay, ease curve visualization, stagger controls, and expanded animation card. Includes border-radius editor dependency from #1217. * feat(studio): gesture recording core Add gesture recording engine with RAF sampling, modifier key property mapping (Shift→rotationXY, Alt→rotation, Cmd→opacity), Ramer-Douglas-Peucker simplification, and ghost trail SVG overlay. * fix(studio): keyframe drag + recording bug bash 21 fixes: capture GSAP base at drag start, translate:none before gsap.set, skip reapplyPathOffsets for GSAP elements, clamp recording seek, _auto flag for 100% keyframes, overlay flash fix, block edits during recording. * feat(studio): keyframe integration wiring + docs Wire App.tsx recording orchestration, TimelineToolbar K/R buttons, PropertyPanel per-property diamonds, shortcuts panel, toast notifications, and keyframes guide documentation. All gated on STUDIO_KEYFRAMES_ENABLED (default false).
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
absoluteToPercentage,
|
||||
absoluteToPercentageForAnimation,
|
||||
findTweenAtTime,
|
||||
isTimeWithinTween,
|
||||
percentageToAbsolute,
|
||||
percentageToAbsoluteForAnimation,
|
||||
resolveTweenDuration,
|
||||
resolveTweenStart,
|
||||
} from "./globalTimeCompiler";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
|
||||
function makeAnim(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
||||
return {
|
||||
id: "#el-to-0",
|
||||
targetSelector: "#el",
|
||||
method: "to",
|
||||
position: 0,
|
||||
properties: { x: 100 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("absoluteToPercentage", () => {
|
||||
test("mid-point of a tween", () => {
|
||||
expect(absoluteToPercentage(0.5, 0, 2)).toBe(25);
|
||||
});
|
||||
|
||||
test("tween with offset start", () => {
|
||||
expect(absoluteToPercentage(1.0, 0.5, 1)).toBe(50);
|
||||
});
|
||||
|
||||
test("clamps below tween start to 0%", () => {
|
||||
expect(absoluteToPercentage(-1, 0, 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("clamps past tween end to 100%", () => {
|
||||
expect(absoluteToPercentage(5, 0, 2)).toBe(100);
|
||||
});
|
||||
|
||||
test("zero duration returns 0", () => {
|
||||
expect(absoluteToPercentage(1, 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("percentageToAbsolute", () => {
|
||||
test("converts percentage back to absolute time", () => {
|
||||
expect(percentageToAbsolute(50, 0.5, 1)).toBe(1.0);
|
||||
});
|
||||
|
||||
test("0% returns tween start", () => {
|
||||
expect(percentageToAbsolute(0, 2, 3)).toBe(2);
|
||||
});
|
||||
|
||||
test("100% returns tween end", () => {
|
||||
expect(percentageToAbsolute(100, 2, 3)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTimeWithinTween", () => {
|
||||
test("time inside returns true", () => {
|
||||
expect(isTimeWithinTween(0.5, 0, 2)).toBe(true);
|
||||
});
|
||||
|
||||
test("time at start returns true", () => {
|
||||
expect(isTimeWithinTween(0, 0, 2)).toBe(true);
|
||||
});
|
||||
|
||||
test("time at end returns true", () => {
|
||||
expect(isTimeWithinTween(2, 0, 2)).toBe(true);
|
||||
});
|
||||
|
||||
test("time before returns false", () => {
|
||||
expect(isTimeWithinTween(-0.1, 0, 2)).toBe(false);
|
||||
});
|
||||
|
||||
test("time after returns false", () => {
|
||||
expect(isTimeWithinTween(2.1, 0, 2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTweenStart", () => {
|
||||
test("numeric position", () => {
|
||||
expect(resolveTweenStart(makeAnim({ position: 1.5 }))).toBe(1.5);
|
||||
});
|
||||
|
||||
test("parseable string position", () => {
|
||||
expect(resolveTweenStart(makeAnim({ position: "2.5" }))).toBe(2.5);
|
||||
});
|
||||
|
||||
test("unparseable string position returns null", () => {
|
||||
expect(resolveTweenStart(makeAnim({ position: "myLabel" }))).toBeNull();
|
||||
});
|
||||
|
||||
test("relative position +=0.5 returns null", () => {
|
||||
expect(resolveTweenStart(makeAnim({ position: "+=0.5" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTweenDuration", () => {
|
||||
test("explicit duration", () => {
|
||||
expect(resolveTweenDuration(makeAnim({ duration: 2 }))).toBe(2);
|
||||
});
|
||||
|
||||
test("missing duration defaults to 1", () => {
|
||||
expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findTweenAtTime", () => {
|
||||
const anims = [
|
||||
makeAnim({ id: "#el-to-0", position: 0, duration: 0.5 }),
|
||||
makeAnim({ id: "#el-to-1", position: 1, duration: 1 }),
|
||||
makeAnim({
|
||||
id: "#other-to-0",
|
||||
targetSelector: "#other",
|
||||
position: 0,
|
||||
duration: 2,
|
||||
}),
|
||||
];
|
||||
|
||||
test("finds tween at time within range", () => {
|
||||
expect(findTweenAtTime(0.3, anims, "#el")?.id).toBe("#el-to-0");
|
||||
});
|
||||
|
||||
test("finds second tween", () => {
|
||||
expect(findTweenAtTime(1.5, anims, "#el")?.id).toBe("#el-to-1");
|
||||
});
|
||||
|
||||
test("returns null for gap between tweens", () => {
|
||||
expect(findTweenAtTime(0.7, anims, "#el")).toBeNull();
|
||||
});
|
||||
|
||||
test("filters by selector", () => {
|
||||
expect(findTweenAtTime(0.3, anims, "#other")?.id).toBe("#other-to-0");
|
||||
});
|
||||
|
||||
test("returns null for unmatched selector", () => {
|
||||
expect(findTweenAtTime(0.3, anims, "#missing")).toBeNull();
|
||||
});
|
||||
|
||||
test("skips tweens with unresolvable string positions", () => {
|
||||
const withLabel = [makeAnim({ id: "#el-to-0", position: "myLabel", duration: 1 })];
|
||||
expect(findTweenAtTime(0.5, withLabel, "#el")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("animation-level helpers", () => {
|
||||
const anim = makeAnim({ position: 0.5, duration: 2 });
|
||||
|
||||
test("absoluteToPercentageForAnimation", () => {
|
||||
expect(absoluteToPercentageForAnimation(1.5, anim)).toBe(50);
|
||||
});
|
||||
|
||||
test("absoluteToPercentageForAnimation returns null for string position", () => {
|
||||
const labelAnim = makeAnim({ position: "label" });
|
||||
expect(absoluteToPercentageForAnimation(0.5, labelAnim)).toBeNull();
|
||||
});
|
||||
|
||||
test("percentageToAbsoluteForAnimation", () => {
|
||||
expect(percentageToAbsoluteForAnimation(50, anim)).toBe(1.5);
|
||||
});
|
||||
|
||||
test("percentageToAbsoluteForAnimation returns null for string position", () => {
|
||||
const labelAnim = makeAnim({ position: "+=1" });
|
||||
expect(percentageToAbsoluteForAnimation(50, labelAnim)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user