Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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
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
93 changes: 18 additions & 75 deletions packages/studio/src/components/editor/AnimationCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,29 +45,34 @@ function selectPreset(host: HTMLElement, presetId: string): string {
return presetConfig.ease;
}

function renderExpandedCard({
animation,
/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
function renderCard({
animation = baseAnimation(),
defaultExpanded = true,
flat,
onUpdateMeta = vi.fn(),
onUpdateKeyframeEase = vi.fn(),
onDeleteAnimation = noop,
}: {
animation: GsapAnimation;
animation?: GsapAnimation;
defaultExpanded?: boolean;
flat?: boolean;
onUpdateMeta?: ReturnType<typeof vi.fn>;
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
}) {
onDeleteAnimation?: (id: string) => void;
} = {}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={animation}
defaultExpanded
defaultExpanded={defaultExpanded}
flat={flat}
onUpdateProperty={noop}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={noop}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={noop}
onRemoveProperty={noop}
onUpdateKeyframeEase={onUpdateKeyframeEase}
Expand All @@ -90,7 +95,7 @@ describe("AnimationCard ease editing", () => {
],
},
});
const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
const view = renderCard({ animation, onUpdateKeyframeEase });

const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("0% → 50%"),
Expand All @@ -101,6 +106,7 @@ describe("AnimationCard ease editing", () => {

expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
action: "commit",
ease,
});
act(() => view.root.unmount());
Expand All @@ -110,7 +116,7 @@ describe("AnimationCard ease editing", () => {
const onUpdateMeta = vi.fn();
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({ id: "flat-tween" });
const view = renderExpandedCard({
const view = renderCard({
animation,
flat: true,
onUpdateMeta,
Expand All @@ -128,70 +134,23 @@ describe("AnimationCard ease editing", () => {

describe("AnimationCard flat branch", () => {
it("renders a mint border-left and panel-token colors when flat", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
flat
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false, flat: true });
const card = host.querySelector('[data-flat-effect-card="true"]');
expect(card).not.toBeNull();
expect(card?.className).toContain("border-panel-accent");
act(() => root.unmount());
});

it("still renders the legacy (non-flat) appearance when flat is omitted", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false });
expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull();
expect(host.textContent).toContain("power2.out");
act(() => root.unmount());
});

it("toggles expanded state when the collapsed header button is clicked, in both modes", () => {
for (const flat of [false, true]) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
flat={flat || undefined}
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false, flat: flat || undefined });
expect(host.textContent).not.toContain("Remove");
const button = host.querySelector("button");
expect(button).not.toBeNull();
Expand All @@ -205,23 +164,7 @@ describe("AnimationCard flat branch", () => {

it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => {
const onDeleteAnimation = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={true}
flat
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ flat: true, onDeleteAnimation });
const buttons = Array.from(host.querySelectorAll("button"));
const removeButton = buttons.find((b) => b.textContent === "Remove");
expect(removeButton).not.toBeUndefined();
Expand Down
28 changes: 26 additions & 2 deletions packages/studio/src/components/editor/AnimationCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
Expand All @@ -23,13 +23,17 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks {
animation: GsapAnimation;
defaultExpanded: boolean;
flat?: boolean;
focusedSegment?: { tweenPercentage: number } | null;
onFocusSegmentConsumed?: () => void;
}

// fallow-ignore-next-line complexity
export const AnimationCard = memo(function AnimationCard({
animation,
defaultExpanded,
flat,
focusedSegment,
onFocusSegmentConsumed,
onUpdateProperty,
onUpdateMeta,
onDeleteAnimation,
Expand All @@ -50,6 +54,25 @@ export const AnimationCard = memo(function AnimationCard({
const [addingProp, setAddingProp] = useState(false);
const [addingFromProp, setAddingFromProp] = useState(false);
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
const pendingAutoScrollRef = useRef(false);

useEffect(() => {
if (!focusedSegment) return;
setExpanded(true);
Comment thread
miguel-heygen marked this conversation as resolved.
pendingAutoScrollRef.current = true;
setExpandedKfPct(focusedSegment.tweenPercentage);
onFocusSegmentConsumed?.();
}, [focusedSegment, onFocusSegmentConsumed]);

useEffect(() => {
if (!pendingAutoScrollRef.current || expandedKfPct === null) return;
const segment = cardRef.current?.querySelector<HTMLElement>(
`[data-ease-segment-pct="${expandedKfPct}"]`,
);
segment?.scrollIntoView({ block: "nearest", behavior: "smooth" });
pendingAutoScrollRef.current = false;
}, [expandedKfPct]);

const usedProps = useMemo(
() => new Set(Object.keys(animation.properties)),
Expand Down Expand Up @@ -154,6 +177,7 @@ export const AnimationCard = memo(function AnimationCard({

return (
<div
ref={cardRef}
data-flat-effect-card={flat ? "true" : undefined}
className={
flat
Expand Down Expand Up @@ -267,7 +291,7 @@ export const AnimationCard = memo(function AnimationCard({
onToggle={setExpandedKfPct}
onEaseCommit={(pct, ease) => {
onUpdateKeyframeEase(animation.id, pct, ease);
trackStudioSegmentEaseEdit({ ease });
trackStudioSegmentEaseEdit({ action: "commit", ease });
}}
onApplyAll={
onSetAllKeyframeEases
Expand Down
68 changes: 68 additions & 0 deletions packages/studio/src/components/editor/GsapAddAnimationControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";

const STYLES = {
classic: {
method:
"rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white",
cancel: "px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300",
trigger: "text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200",
},
flat: {
method:
"rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0",
cancel: "px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1",
trigger: "text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1",
},
};

export function GsapAddAnimationControl({
open,
setOpen,
onAddAnimation,
track,
variant,
}: {
open: boolean;
setOpen: (open: boolean) => void;
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
track: (control: string, name: string) => void;
variant: keyof typeof STYLES;
}) {
const styles = STYLES[variant];

return (
<div className="relative pt-1">
{open ? (
<div className="flex gap-1.5">
{ADD_METHODS.map((method) => (
<button
key={method}
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
track("button", `Add ${method} animation`);
onAddAnimation(method);
setOpen(false);
}}
className={styles.method}
>
{ADD_METHOD_LABELS[method] ?? method}
</button>
))}
<button type="button" onClick={() => setOpen(false)} className={styles.cancel}>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setOpen(true)}
className={styles.trigger}
title="Add a new animation effect to this element"
>
+ Add effect
</button>
)}
</div>
);
}
Loading
Loading