Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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
27 changes: 10 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
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
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/AnimationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,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
48 changes: 36 additions & 12 deletions packages/studio/src/components/editor/KeyframeNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,36 @@ interface KeyframeNavigationProps {

const TOLERANCE = 0.5;

interface NavigableKeyframe {
percentage: number;
tweenPercentage?: number;
properties: Record<string, number | string>;
}

export function getKeyframeNavigationState<Keyframe extends NavigableKeyframe>(
keyframes: readonly Keyframe[],
currentPercentage: number,
property?: string,
) {
const propertyKeyframes = property
? keyframes.filter((keyframe) => property in keyframe.properties)
: keyframes;
return {
propertyKeyframes,
prevKeyframe:
propertyKeyframes
.filter((keyframe) => keyframe.percentage < currentPercentage - TOLERANCE)
.at(-1) ?? null,
nextKeyframe:
propertyKeyframes.find((keyframe) => keyframe.percentage > currentPercentage + TOLERANCE) ??
null,
currentKeyframe:
propertyKeyframes.find(
(keyframe) => Math.abs(keyframe.percentage - currentPercentage) <= TOLERANCE,
) ?? null,
};
}

/**
* Convert a clip-relative percentage (element lifetime, used for display/seek) to
* the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
Expand Down Expand Up @@ -92,18 +122,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
onRemoveKeyframe,
onConvertToKeyframes,
}: KeyframeNavigationProps) {
// Find keyframes that contain this property
const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? [];

const prevKf =
propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null;

const nextKf =
propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null;

const atCurrent =
propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ??
null;
const {
propertyKeyframes,
prevKeyframe: prevKf,
nextKeyframe: nextKf,
currentKeyframe: atCurrent,
} = getKeyframeNavigationState(keyframes ?? [], currentPercentage, property);

// Diamond state
let diamondState: DiamondState;
Expand Down
11 changes: 7 additions & 4 deletions packages/studio/src/components/editor/keyframeRetime.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Boundary cases share an arrange/assert shape on purpose: each case states its
// own window, drag, and expected remap so a failure reads without cross-referencing.
// fallow-ignore-file code-duplication
import { describe, expect, it } from "vitest";
import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";

Expand Down Expand Up @@ -94,12 +97,12 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
expect(r.kind).toBe("resize");
expect(r.position).toBeCloseTo(2, 5); // start unchanged
expect(r.duration).toBeCloseTo(6, 5); // 8 - 2
// abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each
// abs 2/4/8 over the new [2,8] window → 0 / 33.333 / 100. pctRemap carries each
// existing keyframe's old→new tween-%; the commit re-keys in place (value +
// ease + _auto preserved by round-tripping the source node, not re-emitted here).
expect(r.pctRemap).toEqual([
{ from: 0, to: 0 },
{ from: 50, to: 33.3 },
{ from: 50, to: 33.333 },
{ from: 100, to: 100 },
]);
});
Expand All @@ -113,10 +116,10 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
expect(r.kind).toBe("resize");
expect(r.position).toBeCloseTo(0.5, 5);
expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5
// abs 0.5/4/6 over [0.5,6] → 0 / 63.6 / 100.
// abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100.
expect(r.pctRemap).toEqual([
{ from: 0, to: 0 },
{ from: 50, to: 63.6 },
{ from: 50, to: 63.636 },
{ from: 100, to: 100 },
]);
});
Expand Down
3 changes: 1 addition & 2 deletions packages/studio/src/components/editor/keyframeRetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4;
const MIN_TWEEN_DURATION = 0.01;

const round3 = (n: number) => Math.round(n * 1000) / 1000;
const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));

/** Resolve timing for a flat tween's synthesized start/end diamond. */
Expand Down Expand Up @@ -153,7 +152,7 @@ export function resolveKeyframeRetime(opts: {
const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
const absTime =
i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) };
return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) };
});

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,13 @@ export function useTimelineEditCallbacks({
// resizes the tween — position/duration grow so the dragged keyframe lands at
// the drop while every other keyframe keeps its absolute time (value+ease too).
// fallow-ignore-next-line complexity
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
onMoveKeyframe: async (_elId: string, fromClipPct: number, toClipPct: number) => {
const target = resolveKeyframeTarget(fromClipPct);
const sel = domEditSelection;
if (!target || !sel) return;
if (!target || !sel) return false;
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
const tweenStart = anim ? resolveTweenStart(anim) : null;
if (!anim || tweenStart === null) return;
if (!anim || tweenStart === null) return false;
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
const { elements, domClipChildren } = usePlayerStore.getState();
Expand Down Expand Up @@ -200,7 +200,10 @@ export function useTimelineEditCallbacks({
duration: decision.duration,
});
}
} else {
return false;
}
return true;
},
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
for (const anim of selectedGsapAnimations) {
Expand Down
Loading
Loading