Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
e4d7bde
feat(studio): add keyframe timeline state
miguel-heygen Jul 20, 2026
e34e529
fix(studio): keep gsapAnimations in sync with the keyframe cache
miguel-heygen Jul 25, 2026
acf6766
refactor(studio): one owner for clip-relative keyframe rows
miguel-heygen Jul 25, 2026
d05ecb1
fix(studio): scope the per-file keyframe-cache clear to its own keys
miguel-heygen Jul 26, 2026
3c7400a
fix(studio): close the review findings in this PR instead of at the s…
miguel-heygen Jul 27, 2026
b386b55
fix(studio): clamp the timeline scrub to 0 instead of dropping it
miguel-heygen Jul 27, 2026
521bba6
refactor(studio): resolve tween selector ids through the shared reader
miguel-heygen Jul 27, 2026
d518972
feat(studio): add variable timeline timing and layout
miguel-heygen Jul 20, 2026
95213d7
refactor(studio): give resolveTimelineMove a row-based vertical axis
miguel-heygen Jul 25, 2026
4c7703f
refactor(studio): drop the duplicated row-top docblock
miguel-heygen Jul 26, 2026
e36fb38
feat(studio): add timeline keyframe retiming interactions
miguel-heygen Jul 20, 2026
706f537
fix(studio): let Escape cancel a keyframe retime and throttle its pre…
miguel-heygen Jul 25, 2026
6e0118c
refactor(studio): guard the diamond connector's previous keyframe
miguel-heygen Jul 25, 2026
fed5e5b
feat(studio): add timeline property lanes
miguel-heygen Jul 20, 2026
8bb31b3
feat(studio): add keyframe track headers
miguel-heygen Jul 20, 2026
c264cf2
fix(studio): stop lane-header clicks from reaching the track row
miguel-heygen Jul 25, 2026
4ac57a5
refactor(studio): split the track header's lane math out of its JSX
miguel-heygen Jul 25, 2026
f3ab446
refactor(studio): name the non-keyframe header for what it is
miguel-heygen Jul 25, 2026
555d7f7
refactor(studio): drop the dead expandedElement guard
miguel-heygen Jul 26, 2026
2d1b905
feat(studio): consolidate timeline editor callbacks
miguel-heygen Jul 20, 2026
ba8df26
fix(studio): resolve keyframe fallbacks against the clicked element
miguel-heygen Jul 25, 2026
3f93794
fix(studio): resolve lane-header removal against the clicked element
miguel-heygen Jul 25, 2026
8f66b06
fix(studio): retime the dragged element's own keyframe
miguel-heygen Jul 25, 2026
a423c93
refactor(studio): read animation cache keys through their owner
miguel-heygen Jul 26, 2026
9fc0011
fix(studio): give every keyframe callback one mutation frame
miguel-heygen Jul 27, 2026
d570398
fix(studio): harden keyframe editing semantics
miguel-heygen Jul 20, 2026
62e866e
fix(studio): span the clip on the remaining duration-less tween paths
miguel-heygen Jul 25, 2026
cbdd61d
refactor(studio): give every keyframe diamond one identity
miguel-heygen Jul 25, 2026
f5d3c7a
refactor(studio): split the diamond row's data contract into its own …
miguel-heygen Jul 26, 2026
eddc5e9
fix(studio): pass the whole keyframe identity out of the diamond menu
miguel-heygen Jul 27, 2026
6b58678
fix(studio): drop dead keyframe menu wiring and close the R1 review i…
miguel-heygen Jul 27, 2026
4a12eb9
feat(studio): wire expanded keyframe timeline lanes
miguel-heygen Jul 20, 2026
282cc88
fix(studio): address family B timeline review findings
miguel-heygen Jul 25, 2026
099d891
fix(studio): populate keyframe lanes for every timeline composition
miguel-heygen Jul 25, 2026
3e342cf
fix(studio): keep sub-compositions expanded at the end of the timeline
miguel-heygen Jul 25, 2026
629631a
fix(studio): drop the property-group gate in the AST cache load
miguel-heygen Jul 25, 2026
5382644
refactor(studio): tighten the expanded-lane cache and height helpers
miguel-heygen Jul 25, 2026
8b38a85
fix(studio): close the timeline regressions the QA triage attributed …
miguel-heygen Jul 27, 2026
675cbe1
fix(studio): retime flat tween boundaries and target the clicked keyf…
miguel-heygen Jul 27, 2026
b8ff8bf
fix(studio): close the review findings that survived the stack
miguel-heygen Jul 27, 2026
e317f1f
refactor(studio): double-cast test fixtures and split three dense fun…
miguel-heygen Jul 27, 2026
3181657
refactor(studio): share keyframe clip-% precision across lane writers
miguel-heygen Jul 27, 2026
c20c536
fix(studio): commit lane edits through the edited element's own selec…
miguel-heygen Jul 27, 2026
8470b88
fix(studio): settle boundary retimes, delete every keyframed tween, t…
miguel-heygen Jul 27, 2026
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(
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
33 changes: 16 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,10 @@ export function StudioApp() {
reloadPreview: () => setRefreshKey((k) => k + 1),
pendingTimelineEditPathRef,
});
const invalidateGsapCacheRef = useRef<() => void>(() => {});
// Stable identity — what the ref indirection is for. An inline arrow re-created
// the memoized timeline handlers (it is in their deps) on every render.
const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
const timelineEditing = useTimelineEditing({
projectId,
activeCompPath,
Expand All @@ -171,20 +177,11 @@ export function StudioApp() {
sdkSession: editFlowSdkSession,
publishSdkSession: sdkHandle.publish,
forceReloadSdkSession: sdkHandle.forceReload,
invalidateGsapCache,
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 +225,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 @@ -402,12 +398,14 @@ export function StudioApp() {
designPanelActive,
inspectorPanelActive,
inspectorButtonActive,
shouldShowMotionPath,
shouldShowSelectedDomBounds,
} = useInspectorState(
panelLayout.rightPanelTab,
panelLayout.rightInspectorPanes,
panelLayout.rightCollapsed,
isPlaying,
domEditSession.domEditSelection,
gestureState === "recording",
);
useStudioUrlState({
Expand Down Expand Up @@ -558,6 +556,7 @@ export function StudioApp() {
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
setCompIdToSrc={setCompIdToSrc}
setCompositionLoading={setCompositionLoading}
shouldShowMotionPath={shouldShowMotionPath}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
isGestureRecording={gestureState === "recording"}
recordingState={gestureState}
Expand Down
3 changes: 3 additions & 0 deletions packages/studio/src/components/EditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
) => Promise<void> | void;
setCompIdToSrc: (map: Map<string, string>) => void;
setCompositionLoading: (loading: boolean) => void;
shouldShowMotionPath: boolean;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
isGestureRecording?: boolean;
Expand Down Expand Up @@ -90,6 +91,7 @@ export function EditorShell({
handleRazorSplitAll,
setCompIdToSrc,
setCompositionLoading,
shouldShowMotionPath,
shouldShowSelectedDomBounds,
isGestureRecording,
recordingState,
Expand Down Expand Up @@ -149,6 +151,7 @@ export function EditorShell({
onDeleteElement={handleTimelineElementDelete}
previewOverlay={
<PreviewOverlays
shouldShowMotionPath={shouldShowMotionPath}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
blockPreview={blockPreview}
isGestureRecording={isGestureRecording}
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", () => {
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