feat(studio): automation selection slice

This commit is contained in:
Vance Ingalls
2026-08-06 10:59:27 -07:00
parent 04d9491544
commit 3bc085d55f
3 changed files with 59 additions and 1 deletions
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { usePlayerStore } from "./playerStore";
describe("automationSelectionSlice", () => {
it("stores one ordered selection and clears it", () => {
const store = usePlayerStore.getState();
store.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 1 });
const sel = usePlayerStore.getState().automationSelection;
// Ordered on write, so every consumer can assume t0 < t1.
expect(sel).toEqual({ elementKey: "bgm", target: "volume", t0: 1, t1: 2 });
usePlayerStore.getState().clearAutomationSelection();
expect(usePlayerStore.getState().automationSelection).toBeNull();
});
});
@@ -0,0 +1,38 @@
/**
* The active time selection on one automation lane.
*
* A store slice, not lane-local state, for the same reason keyframe selection
* is one: Delete/copy/paste handlers and the shape menu live outside the lane
* component and need to read it. Ephemeral by construction — nothing
* serializes store state, and the selection must never survive into a render.
*/
import type { StoreApi } from "zustand";
export interface AutomationSelection {
/** TimelineElement key (key ?? id) of the clip that owns the lane. */
elementKey: string;
/** Lane target: "volume" or "fx.<nodeId>.<param>". */
target: string;
/** Clip-local seconds; always t0 < t1 (ordered on write). */
t0: number;
t1: number;
}
export interface AutomationSelectionSlice {
automationSelection: AutomationSelection | null;
setAutomationSelection: (sel: AutomationSelection) => void;
clearAutomationSelection: () => void;
}
export function createAutomationSelectionSlice(
set: StoreApi<AutomationSelectionSlice>["setState"],
): AutomationSelectionSlice {
return {
automationSelection: null,
setAutomationSelection: (sel) =>
set({
automationSelection: sel.t0 <= sel.t1 ? sel : { ...sel, t0: sel.t1, t1: sel.t0 },
}),
clearAutomationSelection: () => set({ automationSelection: null }),
};
}
@@ -10,6 +10,10 @@ import {
} from "../../utils/studioUiPreferences";
import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
import {
createAutomationSelectionSlice,
type AutomationSelectionSlice,
} from "./automationSelectionSlice";
export type { KeyframeCacheEntry } from "./keyframeSlice";
@@ -40,7 +44,7 @@ function resolveElementSelection(
};
}
interface PlayerState extends KeyframeSlice {
interface PlayerState extends KeyframeSlice, AutomationSelectionSlice {
isPlaying: boolean;
currentTime: number;
duration: number;
@@ -296,6 +300,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
timelineSessionEpoch: get().timelineSessionEpoch,
})),
...createAutomationSelectionSlice(set),
activeKeyframePct: null,
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
motionPathArmed: false,