mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(studio): add keyframe timeline state
This commit is contained in:
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user