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
41 changes: 41 additions & 0 deletions packages/studio-server/src/routes/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1830,6 +1830,47 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0);
expect(result.after).not.toContain("data-hf-studio-rotation");
});

it("replace-with-keyframes preserves per-segment easing for exact temporal keyframes", async () => {
const projectDir = createProjectDir();
const PATH_COMP = `<!DOCTYPE html><html><body data-duration="32">
<div id="box"></div>
<script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 100 }] }, duration: 16.055, ease: "power1.inOut" }, 12.17);
</script>
</body></html>`;
writeHtml(projectDir, "path.html", PATH_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const anim = await getFirstAnimation(app, "path.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/path.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: "#box",
position: 12.17,
duration: 16.055,
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 23.2, properties: { x: 25, y: 30 } },
{ percentage: 100, properties: { x: 100, y: 100 } },
],
ease: "none",
}),
});
const result = (await res.json()) as { ok: boolean; after: string };

expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.after).toContain('"23.2%"');
expect(result.after).toContain('easeEach: "power1.inOut"');
expect(result.after).toContain('ease: "none"');
expect(result.after).not.toContain("motionPath");
});

it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
Expand Down
15 changes: 15 additions & 0 deletions packages/studio-server/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,7 @@ export type GsapMutationRequest =
auto?: boolean;
}>;
ease?: string;
easeEach?: string;
}
| {
type: "split-animations";
Expand Down Expand Up @@ -1052,6 +1053,18 @@ export type GsapMutationRequest =

type GsapMutationResult = string | { script: string; skippedSelectors: string[] };

function resolveReplacementEaseEach(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟒 Server-side resolveReplacementEaseEach re-parses the entire script on every request that omits easeEach

function resolveReplacementEaseEach(
  scriptText: string,
  request: { animationId: string; easeEach?: string },
): string | undefined {
  if (request.easeEach !== undefined) return request.easeEach;
  const original = parseGsapScriptAcorn(scriptText).animations.find(
    (animation) => animation.id === request.animationId,
  );
  ...
}

Called from both executeGsapMutationAcorn (:1520) and executeGsapMutationRecast (:1891). During a bulk keyframe edit sequence over an arc with N stops, each request re-parses the entire GSAP script AST just to look up source easeEach. Correctness fine but parse cost scales with N. The new client callers (useEnableKeyframes/useGsapKeyframeOps) send ease: 'none' only β€” do NOT send easeEach β€” so this fallback path fires for every real motion-path temporal-keyframe write.

Fix: Client-side: derive easeEach from the local anim record before the fetch, or pass through gsapAnimations lookup. Or server-side: cache the parse across a single request batch.

β€” Review by Rames D Jusso

scriptText: string,
request: { animationId: string; easeEach?: string },
): string | undefined {
if (request.easeEach !== undefined) return request.easeEach;
const original = parseGsapScriptAcorn(scriptText).animations.find(
(animation) => animation.id === request.animationId,
);
if (!original?.arcPath?.enabled) return undefined;
return original?.keyframes?.easeEach ?? original?.ease;
}

// Mutations that can change a position tween's first keyframe (value/existence/timing)
// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards.
// `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts
Expand Down Expand Up @@ -1507,6 +1520,7 @@ function executeGsapMutationAcorn(
body.duration,
body.keyframes,
body.ease,
resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
}
Expand Down Expand Up @@ -1877,6 +1891,7 @@ async function executeGsapMutationRecast(
body.duration,
body.keyframes,
body.ease,
resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
}
Expand Down
28 changes: 11 additions & 17 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react";
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
import { useRenderQueue } from "./components/renders/useRenderQueue";
import { usePlayerStore, type TimelineElement } from "./player";
import { usePlayerStore } from "./player";
import { StudioOverlays } from "./components/StudioOverlays";
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
import { useCaptionStore } from "./captions/store";
Expand All @@ -12,9 +12,12 @@ import { useFileManager } from "./hooks/useFileManager";
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
import { useTimelineEditing } from "./hooks/useTimelineEditing";
import { persistTimelineMoveEditsAtomically } from "./hooks/timelineMoveAdapter";
import {
persistTimelineMoveEditsAtomically,
type TimelineMoveEditsHandler,
type TimelineMoveOperation,
} from "./hooks/timelineMoveAdapter";
import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
import type { TimelineStackingReorderIntent } from "./player/components/timelineStacking";
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
import { useDomEditSession } from "./hooks/useDomEditSession";
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
Expand Down Expand Up @@ -62,7 +65,6 @@ import {
} from "./utils/studioUrlState";
import { trackStudioSessionStart } from "./telemetry/events";
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
type TimelineMoveOperation = Parameters<typeof persistTimelineMoveEditsAtomically>[2];
// fallow-ignore-next-line complexity
export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection();
Expand Down Expand Up @@ -154,6 +156,7 @@ export function StudioApp() {
reloadPreview: () => setRefreshKey((k) => k + 1),
pendingTimelineEditPathRef,
});
const invalidateGsapCacheRef = useRef<() => void>(() => {});
const timelineEditing = useTimelineEditing({
projectId,
activeCompPath,
Expand All @@ -171,20 +174,11 @@ export function StudioApp() {
sdkSession: editFlowSdkSession,
publishSdkSession: sdkHandle.publish,
forceReloadSdkSession: sdkHandle.forceReload,
invalidateGsapCache: () => invalidateGsapCacheRef.current(),
handleDomZIndexReorderCommitRef,
});
const handleTimelineElementsMove = useCallback(
async (
edits: Array<{
element: TimelineElement;
updates: Pick<TimelineElement, "start" | "track"> & {
stackingReorder?: TimelineStackingReorderIntent | null;
};
}>,
coalesceKey?: string,
operation: TimelineMoveOperation = "timing",
coalesceMs?: number,
) => {
const handleTimelineElementsMove: TimelineMoveEditsHandler = useCallback(
async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => {
const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
},
Expand Down Expand Up @@ -228,7 +222,6 @@ export function StudioApp() {
const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
const resetKeyframesRef = useRef<() => boolean>(() => false);
const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
const invalidateGsapCacheRef = useRef<() => void>(() => {});
const { handleCopy, handlePaste, handleCut } = useClipboard({
projectId,
activeCompPath,
Expand Down Expand Up @@ -408,6 +401,7 @@ export function StudioApp() {
panelLayout.rightInspectorPanes,
panelLayout.rightCollapsed,
isPlaying,
domEditSession.domEditSelection,
gestureState === "recording",
);
useStudioUrlState({
Expand Down
51 changes: 48 additions & 3 deletions packages/studio/src/components/TimelineToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { makeSelection } from "../hooks/domSelectionTestHarness";
import { TimelineToolbar } from "./TimelineToolbar";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Expand All @@ -13,12 +15,14 @@ afterEach(() => {
usePlayerStore.setState({ autoKeyframeEnabled: true });
});

function renderToolbar() {
function renderToolbar(
domEditSession?: React.ComponentProps<typeof TimelineToolbar>["domEditSession"],
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<TimelineToolbar />);
root.render(<TimelineToolbar domEditSession={domEditSession} />);
});
return { host, root };
}
Expand Down Expand Up @@ -54,3 +58,44 @@ describe("TimelineToolbar β€” auto-keyframe toggle (#1808)", () => {
act(() => root.unmount());
});
});
describe("TimelineToolbar β€” motion path endpoints", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟒 TimelineToolbar keyboard/button parity: only the 'endpoint disabled' path asserted; 4 new motion-path label branches untested

const button = host.querySelector<HTMLButtonElement>(
  'button[aria-label="Motion path endpoint"]',
);
expect(button?.disabled).toBe(true);

The diff introduces five distinct tooltip/aria-label branches ('Motion path endpoints cannot be removed', 'Extend motion path to playhead (K)', 'Remove waypoint from motion path (K)', 'Add waypoint to motion path (K)', plus the pre-existing non-motion-path branches). The only new test asserts the endpoint case. If a future refactor swaps the arcAnimation-vs-keyframedAnimation resolution order, the wrong label ships silently.

Fix: Add render assertions for each of the four new label branches.

β€” Review by Rames D Jusso

it("does not advertise a destructive keyframe toggle for a required endpoint", () => {
usePlayerStore.setState({ currentTime: 10 });
const animation: GsapAnimation = {
id: "#el-to-0-position",
targetSelector: "#el",
method: "to",
position: 0,
duration: 10,
properties: {},
keyframes: {
format: "object-array",
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: 100, y: 0 } },
],
},
arcPath: {
enabled: true,
autoRotate: false,
segments: [{ curviness: 1 }],
},
};
const element = document.createElement("div");
element.id = "el";
const session = {
domEditSelection: makeSelection("Element", element),
selectedGsapAnimations: [animation],
handleGsapAddAnimation: vi.fn(),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveKeyframe: vi.fn(),
} satisfies NonNullable<React.ComponentProps<typeof TimelineToolbar>["domEditSession"]>;

const { host, root } = renderToolbar(session);
const button = host.querySelector<HTMLButtonElement>(
'button[aria-label="Motion path endpoint"]',
);
expect(button?.disabled).toBe(true);
act(() => root.unmount());
});
});
Loading
Loading