feat(studio): add keyframe timeline state

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-25 21:40:44 +02:00
parent 3b3d4f559c
commit e4d7bde64f
21 changed files with 583 additions and 193 deletions
@@ -0,0 +1,109 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { StoreApi } from "zustand";
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
export interface KeyframeCacheEntry {
format: string;
keyframes: Array<{
percentage: number;
/** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */
tweenPercentage?: number;
/** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */
propertyGroup?: string;
/** Source tween id — lets the inline clip-row ease button target a specific segment. */
animationId?: string;
properties: Record<string, number | string>;
ease?: string;
/** Set when 2+ source animations collide at this percentage (a single inline
* ease button can't target one): the collapsed row hides the button here. */
easeAmbiguous?: boolean;
}>;
ease?: string;
easeEach?: string;
}
export interface KeyframeSlice {
/** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */
selectedKeyframes: Set<string>;
toggleSelectedKeyframe: (key: string) => void;
clearSelectedKeyframes: () => void;
/** Clips whose keyframe property lanes are expanded in the timeline. */
expandedClipIds: Set<string>;
toggleClipExpanded: (id: string) => void;
setClipExpanded: (id: string, expanded: boolean) => void;
/** Union-expand clips (keyframed clips are expanded by default on load). */
expandClips: (ids: readonly string[]) => void;
/** elementId scopes the request to one element so a shared (class-selector)
* animation id can't open the ease editor on the wrong element. */
focusedEaseSegment: { animationId: string; tweenPercentage: number; elementId: string } | null;
setFocusedEaseSegment: (
target: { animationId: string; tweenPercentage: number; elementId: string } | null,
) => void;
/** Keyframe data per element id, populated from parsed GSAP animations. */
keyframeCache: Map<string, KeyframeCacheEntry>;
/** Unmerged source tweens per element; expanded property lanes read this, never keyframeCache. */
gsapAnimations: Map<string, GsapAnimation[]>;
setGsapAnimations: (elementId: string, animations: GsapAnimation[] | undefined) => void;
setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
}
export function createKeyframeSlice(set: StoreApi<KeyframeSlice>["setState"]): KeyframeSlice {
return {
selectedKeyframes: new Set(),
toggleSelectedKeyframe: (key) =>
set((state) => {
const next = new Set(state.selectedKeyframes);
if (next.has(key)) next.delete(key);
else next.add(key);
return { selectedKeyframes: next };
}),
clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }),
expandedClipIds: new Set(),
toggleClipExpanded: (id) =>
set((state) => {
const next = new Set(state.expandedClipIds);
if (next.has(id)) next.delete(id);
else next.add(id);
return { expandedClipIds: next };
}),
setClipExpanded: (id, expanded) =>
set((state) => {
if (state.expandedClipIds.has(id) === expanded) return state;
const next = new Set(state.expandedClipIds);
if (expanded) next.add(id);
else next.delete(id);
return { expandedClipIds: next };
}),
expandClips: (ids) =>
set((state) => {
if (ids.every((id) => state.expandedClipIds.has(id))) return state;
const next = new Set(state.expandedClipIds);
for (const id of ids) next.add(id);
return { expandedClipIds: next };
}),
focusedEaseSegment: null,
setFocusedEaseSegment: (target) => set({ focusedEaseSegment: target }),
keyframeCache: new Map(),
setKeyframeCache: (elementId, data) =>
set((state) => {
const next = new Map(state.keyframeCache);
if (data) next.set(elementId, data);
else next.delete(elementId);
return { keyframeCache: next };
}),
gsapAnimations: new Map(),
setGsapAnimations: (elementId, animations) =>
set((state) => {
const next = new Map(state.gsapAnimations);
if (animations) next.set(elementId, animations);
else next.delete(elementId);
return { gsapAnimations: next };
}),
};
}
@@ -25,6 +25,31 @@ describe("usePlayerStore", () => {
expect(state.loopEnabled).toBe(false);
expect(state.zoomMode).toBe("fit");
expect(state.manualZoomPercent).toBe(100);
expect(state.expandedClipIds).toEqual(new Set());
});
});
describe("expandedClipIds", () => {
it("toggles clip membership", () => {
const store = usePlayerStore.getState();
store.toggleClipExpanded("clip-1");
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"]));
store.toggleClipExpanded("clip-1");
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set());
});
it("sets clip membership idempotently", () => {
const store = usePlayerStore.getState();
store.setClipExpanded("clip-1", true);
store.setClipExpanded("clip-1", true);
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"]));
store.setClipExpanded("clip-1", false);
store.setClipExpanded("clip-1", false);
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set());
});
});
@@ -4,22 +4,9 @@ import type { BeatEditState } from "../../utils/beatEditing";
import type { ClipManifestClip } from "../lib/playbackTypes";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
import { computePinnedZoomPercent } from "../components/timelineZoom";
import { createKeyframeSlice, type KeyframeSlice } from "./keyframeSlice";
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
export interface KeyframeCacheEntry {
format: string;
keyframes: Array<{
percentage: number;
/** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */
tweenPercentage?: number;
/** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */
propertyGroup?: string;
properties: Record<string, number | string>;
ease?: string;
}>;
ease?: string;
easeEach?: string;
}
export type { KeyframeCacheEntry } from "./keyframeSlice";
export interface TimelineElement {
id: string;
@@ -109,7 +96,7 @@ function resolveElementSelection(
};
}
interface PlayerState {
interface PlayerState extends KeyframeSlice {
isPlaying: boolean;
currentTime: number;
duration: number;
@@ -140,11 +127,6 @@ interface PlayerState {
activeTool: TimelineTool;
setActiveTool: (tool: TimelineTool) => void;
/** Set of selected keyframe keys in format `${elementId}:${percentage}`. */
selectedKeyframes: Set<string>;
toggleSelectedKeyframe: (key: string) => void;
clearSelectedKeyframes: () => void;
/** Tween-relative percentage of the last-clicked keyframe diamond. Operations
* (drag, resize, rotate) target this instead of recomputing from playhead. */
activeKeyframePct: number | null;
@@ -193,10 +175,6 @@ interface PlayerState {
toggleSelectedElementId: (id: string) => void;
clearSelection: () => void;
/** Keyframe data per element id, populated from parsed GSAP animations. */
keyframeCache: Map<string, KeyframeCacheEntry>;
setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void;
@@ -327,15 +305,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
activeTool: "select",
setActiveTool: (tool) => set({ activeTool: tool }),
selectedKeyframes: new Set(),
toggleSelectedKeyframe: (key) =>
set((s) => {
const next = new Set(s.selectedKeyframes);
if (next.has(key)) next.delete(key);
else next.add(key);
return { selectedKeyframes: next };
}),
clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }),
...createKeyframeSlice(set),
activeKeyframePct: null,
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
@@ -363,15 +333,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
}),
clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }),
keyframeCache: new Map(),
setKeyframeCache: (elementId, data) =>
set((s) => {
const next = new Map(s.keyframeCache);
if (data) next.set(elementId, data);
else next.delete(elementId);
return { keyframeCache: next };
}),
requestedSeekTime: null,
requestSeek: (time) => set({ requestedSeekTime: time }),
clearSeekRequest: () => set({ requestedSeekTime: null }),
@@ -569,9 +530,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
outPoint: null,
activeTool: "select",
selectedKeyframes: new Set(),
expandedClipIds: new Set(),
selectedElementIds: new Set(),
clipRevealRequest: null,
keyframeCache: new Map(),
gsapAnimations: new Map(),
beatAnalysis: null,
beatEdits: null,
beatUndo: [],