diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index f2db23a3d6..ffd9f61a8c 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -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 = `
+ + +`; + 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); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 01e7c3f841..3ec64bbff1 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -997,6 +997,7 @@ export type GsapMutationRequest = auto?: boolean; }>; ease?: string; + easeEach?: string; } | { type: "split-animations"; @@ -1052,6 +1053,18 @@ export type GsapMutationRequest = type GsapMutationResult = string | { script: string; skippedSelectors: string[] }; +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, + ); + 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 @@ -1507,6 +1520,7 @@ function executeGsapMutationAcorn( body.duration, body.keyframes, body.ease, + resolveReplacementEaseEach(block.scriptText, body), ); return added.script; } @@ -1877,6 +1891,7 @@ async function executeGsapMutationRecast( body.duration, body.keyframes, body.ease, + resolveReplacementEaseEach(block.scriptText, body), ); return added.script; } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index ad131e3a62..7921f28e98 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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"; @@ -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"; @@ -62,7 +65,6 @@ import { } from "./utils/studioUrlState"; import { trackStudioSessionStart } from "./telemetry/events"; import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config"; -type TimelineMoveOperation = Parameters