Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@
"types": "./dist/runtime/clipTree.d.ts",
"environments": ["browser", "bun", "node"]
},
"./runtime/custom-ease": {
"source": "./src/runtime/customEase.ts",
"runtime": "./dist/runtime/customEase.js",
"types": "./dist/runtime/customEase.d.ts",
"environments": ["browser", "bun", "node"]
},
"./runtime/start-expression": {
"source": "./src/runtime/startExpression.ts",
"runtime": "./dist/runtime/startExpression.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@
"import": "./src/runtime/clipTree.ts",
"types": "./src/runtime/clipTree.ts"
},
"./runtime/custom-ease": {
"bun": "./src/runtime/customEase.ts",
"node": "./dist/runtime/customEase.js",
"import": "./src/runtime/customEase.ts",
"types": "./src/runtime/customEase.ts"
},
"./runtime/start-expression": {
"bun": "./src/runtime/startExpression.ts",
"node": "./dist/runtime/startExpression.js",
Expand Down Expand Up @@ -353,6 +359,10 @@
"import": "./dist/runtime/clipTree.js",
"types": "./dist/runtime/clipTree.d.ts"
},
"./runtime/custom-ease": {
"import": "./dist/runtime/customEase.js",
"types": "./dist/runtime/customEase.d.ts"
},
"./runtime/start-expression": {
"import": "./dist/runtime/startExpression.js",
"types": "./dist/runtime/startExpression.d.ts"
Expand Down
1 change: 1 addition & 0 deletions packages/core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"files": [
"src/runtime/clipTree.ts",
"src/runtime/customEase.ts",
"src/runtime/mediaVolumeEnvelope.ts",
"src/runtime/positionEdits.ts",
"src/runtime/protocol.ts",
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import type { EditHistoryKind } from "../utils/editHistory";
import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";

import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useFileManagerContext } from "../contexts/FileManagerContext";
Expand Down Expand Up @@ -156,6 +155,7 @@ export function StudioRightPanel({
handleUpdateArcSegment,
handleUnroll,
handleUpdateKeyframeEase,
handleUpdateSegmentEase,
handleSetAllKeyframeEases,
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
Expand Down Expand Up @@ -406,6 +406,7 @@ export function StudioRightPanel({
onUnroll={handleUnroll}
onUpdateKeyframeEase={handleUpdateKeyframeEase}
onSetAllKeyframeEases={handleSetAllKeyframeEases}
onUpdateSegmentEase={handleUpdateSegmentEase}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
Expand Down
243 changes: 225 additions & 18 deletions packages/studio/src/components/editor/AnimationCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,98 @@

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AnimationCard } from "./AnimationCard";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { EASE_PRESETS } from "./easePresetLibrary";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";

const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const ANIMATION: GsapAnimation = {
id: "position-tween",
targetSelector: "#clip-1",
method: "to",
position: 0,
duration: 2,
ease: "power1.out",
properties: { x: 200 },
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 } },
{ percentage: 100, properties: { x: 200 } },
],
},
};

const FLAT_ANIMATION: GsapAnimation = {
...ANIMATION,
id: "flat-position-tween",
keyframes: undefined,
};

afterEach(() => {
document.body.innerHTML = "";
trackStudioSegmentEaseEdit.mockClear();
});

function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
return {
id: "anim-1",
method: "to",
position: 0.8,
duration: 1.2,
ease: "power2.out",
properties: { opacity: 1 },
...overrides,
} as GsapAnimation;
function renderFocusCard(
focusedSegment: {
tweenPercentage: number;
collidingAnimationTargets?: AnimationKeyframeTarget[];
} | null,
onEaseCommit = vi.fn(),
defaultExpanded = false,
animation = ANIMATION,
onUpdateMeta = vi.fn(),
onUpdateSegmentEase = vi.fn(),
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (nextFocusedSegment: { tweenPercentage: number } | null) => {
act(() => {
root.render(
<AnimationCard
animation={animation}
defaultExpanded={defaultExpanded}
focusedSegment={nextFocusedSegment}
onFocusSegmentConsumed={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
onUpdateKeyframeEase={onEaseCommit}
onUpdateSegmentEase={onUpdateSegmentEase}
/>,
);
});
};
render(focusedSegment);
return { host, root, render };
}

const noop = () => {};
function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
return Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes(text),
);
}

function openSegment(host: HTMLElement, label: string): void {
const segment = findButton(host, label);
expect(segment).toBeDefined();
act(() => segment?.click());
}

function selectPreset(host: HTMLElement, presetId: string): string {
const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);

const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
expect(dropdown).not.toBeNull();
act(() => dropdown?.click());
Expand All @@ -45,6 +104,8 @@ function selectPreset(host: HTMLElement, presetId: string): string {
return presetConfig.ease;
}

const noop = () => {};

/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
function renderCard({
animation = baseAnimation(),
Expand Down Expand Up @@ -82,7 +143,157 @@ function renderCard({
return { host, root };
}

function restoreScrollIntoView(descriptor: PropertyDescriptor | undefined): void {
if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor);
else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
}

describe("AnimationCard", () => {
it("scrolls a focused segment into view but not a manually toggled segment", () => {
const originalScrollIntoView = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"scrollIntoView",
);
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});

const view = renderFocusCard({ tweenPercentage: 50 });
try {
expect(scrollIntoView).toHaveBeenCalledOnce();
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });

view.render(null);
const manualToggle = findButton(view.host, "50% → 100%");
expect(manualToggle).toBeDefined();
act(() => manualToggle?.click());
expect(scrollIntoView).toHaveBeenCalledOnce();
} finally {
act(() => view.root.unmount());
restoreScrollIntoView(originalScrollIntoView);
}
});

it("tracks a committed segment ease alongside the existing update", () => {
const onEaseCommit = vi.fn();
const view = renderFocusCard(null, onEaseCommit, true);
openSegment(view.host, "0% → 50%");
const ease = selectPreset(view.host, "quad-out");

expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease);
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "commit", ease });
act(() => view.root.unmount());
});

it("commits a focused multi-id segment ease through the bulk callback", () => {
const onUpdateKeyframeEase = vi.fn();
const onUpdateSegmentEase = vi.fn();
const collidingAnimationTargets = [
{ animationId: ANIMATION.id, tweenPercentage: 50 },
{ animationId: "scale-tween", tweenPercentage: 75 },
{ animationId: "opacity-tween", tweenPercentage: 25 },
];
const view = renderFocusCard(
{ tweenPercentage: 50, collidingAnimationTargets },
onUpdateKeyframeEase,
false,
ANIMATION,
vi.fn(),
onUpdateSegmentEase,
);
const ease = selectPreset(view.host, "quad-out");

expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(collidingAnimationTargets, ease);
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
act(() => view.root.unmount());
});

it("keeps a focused single-id segment ease on the single callback", () => {
const onUpdateKeyframeEase = vi.fn();
const onUpdateSegmentEase = vi.fn();
const view = renderFocusCard(
{
tweenPercentage: 50,
collidingAnimationTargets: [{ animationId: ANIMATION.id, tweenPercentage: 50 }],
},
onUpdateKeyframeEase,
false,
ANIMATION,
vi.fn(),
onUpdateSegmentEase,
);
const ease = selectPreset(view.host, "quad-out");

expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease);
expect(onUpdateSegmentEase).not.toHaveBeenCalled();
act(() => view.root.unmount());
});

it("commits a focused flat tween segment ease through tween metadata", () => {
const onUpdateMeta = vi.fn();
const onUpdateKeyframeEase = vi.fn();
const view = renderFocusCard(
{ tweenPercentage: 100 },
onUpdateKeyframeEase,
false,
FLAT_ANIMATION,
onUpdateMeta,
);
const ease = selectPreset(view.host, "quad-out");

expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(FLAT_ANIMATION.id, { ease });
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
});

function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
return {
id: "anim-1",
method: "to",
position: 0.8,
duration: 1.2,
ease: "power2.out",
properties: { opacity: 1 },
...overrides,
} as GsapAnimation;
}

describe("AnimationCard ease editing", () => {
it.each([
["spring", "power2.out", "spring(0.42)", "Spring bounce"],
["wiggle", "power2.out", "wiggle(3,easeInOut,0.12)", "Wiggle count"],
["curve", "spring(0.6)", "custom(M0,0 C0.16,1 0.3,1 1,1)", "Cubic bezier control points"],
] as const)(
"commits and immediately displays the %s default when a keyframe segment switches mode",
(mode, currentEase, ease, fieldLabel) => {
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 50, properties: { opacity: 0.5 }, ease: currentEase },
{ percentage: 100, properties: { opacity: 1 } },
],
},
});
const view = renderFocusCard(null, onUpdateKeyframeEase, true, animation);

openSegment(view.host, "0% → 50%");
const modeButton = view.host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
expect(modeButton).not.toBeNull();
act(() => modeButton?.click());

expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
expect(modeButton?.getAttribute("aria-checked")).toBe("true");
expect(view.host.querySelector(`[aria-label="${fieldLabel}"]`)).not.toBeNull();
act(() => view.root.unmount());
},
);

it("commits one preset change to the selected keyframe segment", () => {
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({
Expand All @@ -97,11 +308,7 @@ describe("AnimationCard ease editing", () => {
});
const view = renderCard({ animation, onUpdateKeyframeEase });

const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("0% → 50%"),
);
expect(segment).toBeDefined();
act(() => segment?.click());
openSegment(view.host, "0% → 50%");
const ease = selectPreset(view.host, "quad-out");

expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
Expand Down
Loading
Loading