Files
hyperframes/packages/studio/src/player/store/playerStore.ts
T
Vance Ingalls a44ebd7573 fix(studio): compare the reveal request across the id-space boundary
The previous commit's store guard was silently never true. A reveal request
carries the BARE dom id — `revealElementId = runtimeAudioId(keyframeClip)`,
because the panel and the runtime speak that — while the store's ids are
`sourceFile#domId`. So `elementKey === id` compared "music-bed" against
"index.html#music-bed" and the request was still cleared by its own selection.

This is the id-space boundary the branch's own handoff names as the trap most
likely to be re-broken, and it fails exactly as documented: no error, the
feature just never matches. Caught only by driving the real UI — the store said
`selected: "index.html#music-bed"` and `revealSurvived: null` in the same read.

`revealTargetsSelection` now splits the key with the existing
`splitTimelineElementKey` and is a named function in `playerStoreSelection.ts`
so the crossing is stated once, where it happens.

**Verified in the browser, both halves, which had never been watched working:**
- clip NOT selected: clicking a lane label selects the clip, the rack opens, and
  the carve module expands (correct — that lane's node is a carve band). The
  request is consumed and retired to null by the new unmount clear.
- clip ALREADY selected: collapsing the module and clicking the same lane again
  reopens it, which the old value-equality consumption could not do.

studio player: 103 files, 1355 tests. fallow clean.
2026-08-20 16:41:24 -07:00

601 lines
24 KiB
TypeScript

import { create } from "zustand";
import { attachPlayerStoreDevHandle } from "./playerStoreDevHandle";
import { nextSelectionSet, revealTargetsSelection } from "./playerStoreSelection";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { BeatEditState } from "../../utils/beatEditing";
import type { ClipManifestClip } from "../lib/playbackTypes";
import {
readStudioUiPreferences,
writeStudioUiPreferences,
type TimelineTimeDisplayMode,
} from "../../utils/studioUiPreferences";
import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
import {
createAutomationSelectionSlice,
type AutomationSelectionSlice,
} from "./automationSelectionSlice";
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
export type { KeyframeCacheEntry } from "./keyframeSlice";
export { liveTime } from "./liveTime";
import type { TimelineElement, TimelineElementPatch } from "./timelineElement";
export type { TimelineElement };
export type ZoomMode = "fit" | "manual";
type TimelineTool = "select" | "razor";
export interface SelectElementOptions {
preserveSet?: boolean;
}
function resolveElementSelection(
ids: Iterable<string>,
anchor?: string | null,
): { selectedElementIds: Set<string>; selectedElementId: string | null } {
const selectedElementIds = new Set(ids);
if (selectedElementIds.size === 0) {
return { selectedElementIds, selectedElementId: null };
}
if (anchor && selectedElementIds.has(anchor)) {
return { selectedElementIds, selectedElementId: anchor };
}
return {
selectedElementIds,
selectedElementId: selectedElementIds.values().next().value ?? null,
};
}
interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice {
isPlaying: boolean;
currentTime: number;
duration: number;
timelineReady: boolean;
/** Increments exactly once when the Studio switches to a different project. */
timelineSessionEpoch: number;
/** Project owning the current timeline session; null outside a project-scoped reset. */
timelineProjectId: string | null;
/** True while a beat dot is being dragged — hides the playhead guideline. */
beatDragging: boolean;
elements: TimelineElement[];
selectedElementId: string | null;
playbackRate: number;
audioMuted: boolean;
audioVolume: number;
loopEnabled: boolean;
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
zoomMode: ZoomMode;
/** Timeline zoom percent relative to the fit width when in manual mode */
manualZoomPercent: number;
/**
* Bumped on every live z-index edit (handleDomZIndexReorderCommit apply AND
* rollback). Flashless z commits (skipReload) never reload the iframe or
* bump refreshKey, so DOM-derived views (the Layers panel's z-sorted tree)
* subscribe to this to re-read the live DOM while playback is paused.
*/
zEditVersion: number;
/** Work-area in-point (seconds). When set, loop starts here and A jumps here. */
inPoint: number | null;
/** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
outPoint: number | null;
activeTool: TimelineTool;
setActiveTool: (tool: TimelineTool) => void;
/** Tween-relative percentage of the last-clicked keyframe diamond. Operations
* (drag, resize, rotate) target this instead of recomputing from playhead. */
activeKeyframePct: number | null;
setActiveKeyframePct: (pct: number | null) => void;
/** Motion-path "set destination" mode. Armed from the preview toolbar (replaces
* the old double-click-on-canvas UX); while armed, one canvas click places the
* new path's destination. `available` is published by MotionPathOverlay so the
* toolbar shows the button only when the selected element can take a path. */
motionPathArmed: boolean;
setMotionPathArmed: (armed: boolean) => void;
motionPathCreateAvailable: boolean;
setMotionPathCreateAvailable: (available: boolean) => void;
/** Global toggle for the "Add keyframe" diamond in the timeline toolbar (#1808).
* When false, a manual drag/resize/rotate edit on an element that already has
* a live tween shifts every keyframe by the edit's delta (preserving the
* animation's shape) instead of inserting/updating a keyframe at the playhead. */
autoKeyframeEnabled: boolean;
setAutoKeyframeEnabled: (enabled: boolean) => void;
/** Multi-select: additional selected elements beyond selectedElementId. */
selectedElementIds: Set<string>;
clearSelectedElementIds: () => void;
/** Replace the whole multi-selection at once (marquee live updates). */
setSelectedElementIds: (ids: Set<string>) => void;
/** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
timelineSnapEnabled: boolean;
setTimelineSnapEnabled: (enabled: boolean) => void;
/** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
timeDisplayMode: TimelineTimeDisplayMode;
setTimeDisplayMode: (mode: TimelineTimeDisplayMode) => void;
/**
* Pin the timeline zoom to its current visual scale before a duration-changing
* edit, so a subsequent duration change (which recomputes fit-pps) stops
* rescaling every clip. No-op once already pinned (mode is "manual").
*/
pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
/** The timeline's live pixels-per-second + fit basis, published by <Timeline>. */
timelinePps: number;
timelineFitPps: number;
setTimelineScale: (pps: number, fitPps: number) => void;
setSelection: (ids: Iterable<string>, anchor?: string | null) => void;
addSelectedElementId: (id: string) => void;
toggleSelectedElementId: (id: string) => void;
clearSelection: () => void;
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void;
setPlaybackRate: (rate: number) => void;
setAudioMuted: (muted: boolean) => void;
setAudioVolume: (volume: number) => void;
setLoopEnabled: (enabled: boolean) => void;
setTimelineReady: (ready: boolean) => void;
setBeatDragging: (dragging: boolean) => void;
setElements: (elements: TimelineElement[]) => void;
setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
/** Move the selection anchor within an active multi-selection without collapsing it. */
setSelectionAnchor: (id: string | null) => void;
updateElement: (elementId: string, updates: TimelineElementPatch) => void;
setZoomMode: (mode: ZoomMode) => void;
setManualZoomPercent: (percent: number) => void;
bumpZEditVersion: () => void;
setInPoint: (time: number | null) => void;
setOutPoint: (time: number | null) => void;
/** Owns the hard project boundary; repeated calls for one project are no-ops. */
beginTimelineSession: (projectId: string) => void;
/** Clears project data without creating a new hard-project session. */
reset: () => void;
/**
* Request a seek from outside the player loop (e.g. Layers panel).
* useTimelinePlayer subscribes and calls adapter.seek() + liveTime.notify().
*/
requestedSeekTime: number | null;
requestSeek: (time: number) => void;
clearSeekRequest: () => void;
/**
* Request the transport start or stop from outside the player loop.
*
* The FX rack auditions a preset by writing it to the running graph, which is
* silent while the transport is paused — so hovering one has to start
* playback, and leaving has to put the playhead back where it was. Hovering is
* not an edit and must not cost the author their place.
*
* A nonce rather than a bare boolean: two hovers in a row both want play, and
* without it the second request is indistinguishable from the first having
* already been served.
*/
playbackRequest: { playing: boolean; returnTo: number | null; nonce: number } | null;
requestPlayback: (playing: boolean, returnTo?: number | null) => void;
clearPlaybackRequest: () => void;
/**
* Request the timeline to scroll a clip into view (e.g. clicking an
* already-added asset card in the sidebar). Consumed and cleared by
* useTimelineRevealClip. The nonce makes repeat requests for the same
* clip observable so a second click re-reveals after the user scrolls away.
*/
clipRevealRequest: { elementId: string; nonce: number } | null;
requestClipReveal: (elementId: string) => void;
clearClipRevealRequest: () => void;
timelineFocus: TimelineFocusRequest | null;
timelineFocusNonce: number;
requestTimelineFocus: (id: string) => void;
clearTimelineFocus: (nonce: number) => void;
lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
beatAnalysis: MusicBeatAnalysis | null;
setBeatAnalysis: (analysis: MusicBeatAnalysis | null) => void;
/** User edits (add/move/delete) layered over the detected beat grid. */
beatEdits: BeatEditState | null;
setBeatEdits: (edits: BeatEditState | null) => void;
/** Undo/redo stacks for beat edits (in-memory, session-only). */
beatUndo: BeatHistoryEntry[];
beatRedo: BeatHistoryEntry[];
commitBeatEdits: (next: BeatEditState | null, label: string) => void;
undoBeatEdits: () => string | null;
redoBeatEdits: () => string | null;
resetBeatHistory: () => void;
beatPersist: (() => void) | null;
setBeatPersist: (fn: (() => void) | null) => void;
clipManifest: ClipManifestClip[] | null;
setClipManifest: (clips: ClipManifestClip[] | null) => void;
clipParentMap: Map<string, string>;
setClipParentMap: (map: Map<string, string>) => void;
/**
* Sub-composition DOM descendants (groups + their children) that have no
* `data-start`, so they're absent from the clip manifest/tree. Collected
* studio-side from the live preview so the timeline can expand a sub-comp row
* to show its DOM-only children. Keeps the manifest lean (timed clips only).
*/
domClipChildren: DomClipChild[];
setDomClipChildren: (children: DomClipChild[]) => void;
}
/** A sub-comp DOM-only timeline child (no data-start) and its nesting context. */
export interface DomClipChild {
id: string;
parentId: string;
/** The manifest sub-comp host clip id this descendant ultimately lives under. */
hostId: string;
label: string;
stackingContextId: string;
/**
* The child's audio-group state, read off its live element during the DOM
* walk — the only place that sees it. A sub-composition can declare a group
* and its members entirely within itself, and those members never reach the
* flat store, so an expanded child has no twin to inherit membership from.
*/
audioGroup?: string;
audioGroupLabel?: string;
audioGroupVolume?: number;
audioGroupHidden?: boolean;
audioGroupFxChain?: string;
/** The group element's `data-automation`, mirrored the same way. */
audioGroupAutomation?: string;
}
interface BeatHistoryEntry {
restore: BeatEditState | null;
at: number;
label: string;
}
export function createTimelineResetState() {
return {
isPlaying: false,
currentTime: 0,
duration: 0,
timelineReady: false,
beatDragging: false,
elements: [],
selectedElementId: null,
zEditVersion: 0,
inPoint: null,
outPoint: null,
activeTool: "select" as const,
activeKeyframePct: null,
motionPathArmed: false,
motionPathCreateAvailable: false,
selectedKeyframes: new Set<string>(),
// Ephemeral like every other selection here. A range surviving a project
// switch can match a same-keyed clip in the new project and redirect a
// paste through `sel.elementKey === paste.elementKey` to a stale t0.
automationSelection: null,
expandedClipIds: new Set<string>(),
// Per-composition: ids from comp A match nothing in B, silencing all of it.
collapsedGroupIds: new Set<string>(),
expandedLaneOwnerIds: new Set<string>(),
focusedEaseSegment: null,
revealedAudioFxTarget: null,
selectedElementIds: new Set<string>(),
requestedSeekTime: null,
lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
timelineFocus: null,
keyframeCache: new Map<string, KeyframeCacheEntry>(),
gsapAnimations: new Map<string, GsapAnimation[]>(),
beatAnalysis: null,
beatEdits: null,
beatUndo: [],
beatRedo: [],
beatPersist: null,
clipManifest: null,
clipParentMap: new Map<string, string>(),
domClipChildren: [],
};
}
export const usePlayerStore = create<PlayerState>((set, get) => ({
isPlaying: false,
currentTime: 0,
duration: 0,
timelineReady: false,
timelineSessionEpoch: 0,
timelineProjectId: null,
beatDragging: false,
elements: [],
selectedElementId: null,
playbackRate: readStudioUiPreferences().playbackRate ?? 1,
audioMuted: readStudioUiPreferences().audioMuted ?? false,
audioVolume: readStudioUiPreferences().audioVolume ?? 1,
loopEnabled: false,
zoomMode: "fit",
manualZoomPercent: 100,
zEditVersion: 0,
timelinePps: 100,
timelineFitPps: 100,
inPoint: null,
outPoint: null,
activeTool: "select",
setActiveTool: (tool) => set({ activeTool: tool }),
...createKeyframeSlice(set, () => ({
timelineProjectId: get().timelineProjectId,
timelineSessionEpoch: get().timelineSessionEpoch,
})),
...createThumbnailSlice(set),
...createAutomationSelectionSlice(set),
activeKeyframePct: null,
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
motionPathArmed: false,
setMotionPathArmed: (armed) => set({ motionPathArmed: armed }),
motionPathCreateAvailable: false,
setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
autoKeyframeEnabled: true,
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
selectedElementIds: new Set<string>(),
setSelection: (ids, anchor) => set(resolveElementSelection(ids, anchor)),
addSelectedElementId: (id: string) =>
set((s) => {
const next = new Set(s.selectedElementIds);
next.add(id);
return resolveElementSelection(next, s.selectedElementId);
}),
toggleSelectedElementId: (id: string) =>
set((s) => {
const next = new Set(s.selectedElementIds);
if (next.has(id)) next.delete(id);
else next.add(id);
return resolveElementSelection(next, s.selectedElementId);
}),
clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }),
requestedSeekTime: null,
requestSeek: (time) => set({ requestedSeekTime: time }),
clearSeekRequest: () => set({ requestedSeekTime: null }),
playbackRequest: null,
requestPlayback: (playing, returnTo = null) =>
set((s) => ({
playbackRequest: { playing, returnTo, nonce: (s.playbackRequest?.nonce ?? 0) + 1 },
})),
clearPlaybackRequest: () => set({ playbackRequest: null }),
clipRevealRequest: null,
requestClipReveal: (elementId) =>
set((s) => ({
clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
})),
clearClipRevealRequest: () => set({ clipRevealRequest: null }),
timelineFocus: null,
timelineFocusNonce: 0,
requestTimelineFocus: (id) =>
set((s) => {
const nonce = s.timelineFocusNonce + 1;
return {
timelineFocusNonce: nonce,
timelineFocus: createTimelineFocusRequest(
id,
s.timelineProjectId,
s.timelineSessionEpoch,
nonce,
),
};
}),
clearTimelineFocus: (nonce) =>
set((s) => (s.timelineFocus?.nonce === nonce ? { timelineFocus: null } : s)),
lintFindingsByElement: new Map(),
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
beatAnalysis: null,
setBeatAnalysis: (analysis) => set({ beatAnalysis: analysis }),
beatEdits: null,
setBeatEdits: (edits) => set({ beatEdits: edits }),
beatUndo: [],
beatRedo: [],
beatPersist: null,
setBeatPersist: (fn) => set({ beatPersist: fn }),
commitBeatEdits: (next, label) => {
set((s) => ({
beatEdits: next,
beatUndo: [...s.beatUndo, { restore: s.beatEdits, at: Date.now(), label }],
beatRedo: [],
}));
get().beatPersist?.();
},
undoBeatEdits: () => {
const s = get();
const entry = s.beatUndo[s.beatUndo.length - 1];
if (!entry) return null;
set({
beatEdits: entry.restore,
beatUndo: s.beatUndo.slice(0, -1),
beatRedo: [...s.beatRedo, { restore: s.beatEdits, at: entry.at, label: entry.label }],
});
get().beatPersist?.();
return entry.label;
},
resetBeatHistory: () => set({ beatUndo: [], beatRedo: [] }),
redoBeatEdits: () => {
const s = get();
const entry = s.beatRedo[s.beatRedo.length - 1];
if (!entry) return null;
set({
beatEdits: entry.restore,
beatRedo: s.beatRedo.slice(0, -1),
beatUndo: [...s.beatUndo, { restore: s.beatEdits, at: entry.at, label: entry.label }],
});
get().beatPersist?.();
return entry.label;
},
clipManifest: null,
setClipManifest: (clips) => set({ clipManifest: clips }),
clipParentMap: new Map(),
setClipParentMap: (map) => set({ clipParentMap: map }),
domClipChildren: [],
setDomClipChildren: (children) => set({ domClipChildren: children }),
setIsPlaying: (playing) => {
if (get().isPlaying === playing) return;
set({ isPlaying: playing });
},
setPlaybackRate: (rate) => {
writeStudioUiPreferences({ playbackRate: rate });
set({ playbackRate: rate });
},
setAudioMuted: (muted) => {
writeStudioUiPreferences({ audioMuted: muted });
set({ audioMuted: muted });
},
setAudioVolume: (volume) => {
const nextVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
writeStudioUiPreferences({ audioVolume: nextVolume });
set({ audioVolume: nextVolume });
},
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
setZoomMode: (mode) => set({ zoomMode: mode }),
clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
setSelectedElementIds: (ids: Set<string>) => set({ selectedElementIds: new Set(ids) }),
timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true,
setTimelineSnapEnabled: (enabled) => {
writeStudioUiPreferences({ timelineSnapEnabled: enabled });
set({ timelineSnapEnabled: enabled });
},
timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
setTimeDisplayMode: (mode) => {
writeStudioUiPreferences({ timeDisplayMode: mode });
set({ timeDisplayMode: mode });
},
pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
set((s) => {
// Already pinned (or the user manually zoomed) — never clobber that.
if (s.zoomMode === "manual") return {};
const percent = computePinnedZoomPercent(currentPixelsPerSecond, fitPixelsPerSecond);
writeStudioUiPreferences({
timelineZoomMode: "manual",
timelineManualZoomPercent: percent,
});
return { zoomMode: "manual", manualZoomPercent: percent };
}),
setTimelineScale: (pps, fitPps) => {
const state = get();
if (state.timelinePps === pps && state.timelineFitPps === fitPps) return;
set({ timelinePps: pps, timelineFitPps: fitPps });
},
setInPoint: (time) =>
set((state) => {
const t = time !== null && Number.isFinite(time) ? time : null;
return {
inPoint: t,
outPoint:
t !== null && state.outPoint !== null && t >= state.outPoint ? null : state.outPoint,
// Setting a work-area marker implies the user wants playback bounded by it.
// Auto-enable loop so the playhead respects the marker instead of running past.
loopEnabled: t !== null ? true : state.loopEnabled,
};
}),
setOutPoint: (time) =>
set((state) => {
const t = time !== null && Number.isFinite(time) ? time : null;
return {
outPoint: t,
inPoint: t !== null && state.inPoint !== null && t <= state.inPoint ? null : state.inPoint,
loopEnabled: t !== null ? true : state.loopEnabled,
};
}),
setManualZoomPercent: (percent) =>
set((state) => ({
manualZoomPercent: clampTimelineZoomPercent(percent, state.timelineFitPps),
})),
bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
setTimelineReady: (ready) => set({ timelineReady: ready }),
setBeatDragging: (dragging) => set({ beatDragging: dragging }),
setElements: (elements) => set({ elements }),
// A genuine single selection: always collapse the set to just this element. User
// intent (timeline click, preview click via applyDomSelection) flows here; DOM sync
// echoes that must preserve a group go through setSelectionAnchor instead.
setSelectedElementId: (id, options) =>
set((s) => {
const selectedElementIds = nextSelectionSet(s.selectedElementIds, id, options?.preserveSet);
// Selecting a different element drops any active keyframe selection — otherwise
// a stale activeKeyframePct from a prior diamond click would force the next drag
// to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
// calling setSelectedElementId, so this never clobbers a genuine keyframe select.
// A reveal request survives the selection it is FOR. `openClipFxRack`
// raises the request and then selects the clip asynchronously, so the
// selection lands afterwards and used to clear the very request that
// caused it — the panel then read null and the section never opened.
// Any OTHER selection still drops it: a request aimed elsewhere is stale.
//
// Compared across the ID-SPACE BOUNDARY, which is why this needs saying:
// a request carries the BARE dom id (`runtimeAudioId`, because the panel
// and the runtime speak that), while this store's ids are
// `sourceFile#domId`. A direct `===` was silently never true — the exact
// shape of failure the id-space split produces.
const revealSurvives = revealTargetsSelection(s.revealedAudioFxTarget, id);
return id !== s.selectedElementId
? {
selectedElementId: id,
selectedElementIds,
activeKeyframePct: null,
motionPathArmed: false,
focusedEaseSegment: null,
...(revealSurvives ? {} : { revealedAudioFxTarget: null }),
}
: { selectedElementId: id, selectedElementIds };
}),
// Move the anchor within an active multi-selection WITHOUT collapsing it — used by
// DOM->store sync echoes while a group gesture re-patches the preview. A non-member
// id is treated as a genuine new single selection.
setSelectionAnchor: (id) =>
set((s) => {
if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) {
return {
selectedElementId: id,
focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
};
}
return {
selectedElementId: id,
selectedElementIds: id ? new Set([id]) : new Set<string>(),
focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
};
}),
updateElement: (elementId, updates) =>
set((state) => ({
elements: state.elements.map((el) =>
(el.key ?? el.id) === elementId ? { ...el, ...updates } : el,
),
})),
// UI preferences intentionally survive reset. So do timelineSessionEpoch and
// focusedEaseRequestNonce: the epoch advances only when project identity
// changes, while a monotonic nonce prevents collisions with stale consumers.
beginTimelineSession: (projectId) =>
set((state) => {
if (state.timelineProjectId === projectId) return state;
return {
...createTimelineResetState(),
timelineSessionEpoch: state.timelineSessionEpoch + 1,
timelineProjectId: projectId,
};
}),
reset: () => set(createTimelineResetState()),
}));
attachPlayerStoreDevHandle(usePlayerStore);