@hyperframes/studio 0.8.53 → 0.8.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/assets/{hyperframes-player-DKWM-Ons.js → hyperframes-player-BjAGc2ID.js} +1 -1
  2. package/dist/assets/{index-Y4OnpTEM.js → index-BEIRytvV.js} +1 -1
  3. package/dist/assets/{index-UvW6BEwf.js → index-DaNpNFJC.js} +1 -1
  4. package/dist/assets/{index-HfP3sap2.js → index-TcBZyzNA.js} +191 -191
  5. package/dist/index.d.ts +440 -10
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1891 -1544
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/nle/TimelinePane.tsx +11 -1
  11. package/src/hooks/gsapDragCommit.ts +1 -1
  12. package/src/hooks/gsapEditOutcome.test.ts +115 -0
  13. package/src/hooks/gsapEditOutcome.ts +39 -10
  14. package/src/hooks/gsapResizeIntercept.test.ts +42 -3
  15. package/src/hooks/gsapResizeIntercept.ts +12 -2
  16. package/src/hooks/gsapRuntimeBridge.test.ts +10 -2
  17. package/src/hooks/gsapRuntimeBridge.ts +14 -3
  18. package/src/hooks/timelineAudioGroupCreate.ts +17 -1
  19. package/src/hooks/timelineAudioGroupVolume.ts +35 -2
  20. package/src/hooks/timelineEditPermission.ts +31 -0
  21. package/src/hooks/timelineElementFxAttribute.ts +34 -2
  22. package/src/hooks/useFileManager.ts +21 -123
  23. package/src/hooks/usePersistentEditHistory.ts +1 -1
  24. package/src/hooks/useProjectFileWriter.ts +150 -0
  25. package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
  26. package/src/hooks/useTimelineAssetDropOps.ts +40 -3
  27. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  28. package/src/hooks/useTimelineEditing.test.tsx +481 -4
  29. package/src/hooks/useTimelineEditing.ts +151 -27
  30. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  31. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  32. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  33. package/src/hostMount.test.tsx +222 -0
  34. package/src/index.ts +40 -2
  35. package/src/player/components/Timeline.tsx +6 -6
  36. package/src/player/components/TimelineTypes.ts +3 -0
  37. package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
  38. package/src/player/components/usePublishRangeSelection.ts +42 -0
  39. package/src/player/index.ts +1 -0
  40. package/src/player/store/playerStore.ts +8 -51
  41. package/src/player/store/rangeSelectionSlice.ts +13 -0
  42. package/src/player/store/timelineResetState.ts +51 -0
  43. package/src/utils/studioSaveDiagnostics.ts +12 -0
@@ -4,6 +4,7 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
4
4
  import { useTimelineRowElements } from "../hooks/useTimelineRowElements";
5
5
  import { defaultTimelineTheme } from "./timelineTheme";
6
6
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
7
+ import { usePublishRangeSelection } from "./usePublishRangeSelection";
7
8
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
8
9
  import { useTimelineZoom } from "./useTimelineZoom";
9
10
  import { useTimelineAssetDrop } from "./timelineDragDrop";
@@ -80,6 +81,7 @@ export const Timeline = memo(function Timeline({
80
81
  onBlockedEditAttempt: onBlockedEditAttemptOverride,
81
82
  onSplitElement: onSplitElementOverride,
82
83
  onSelectElement,
84
+ onRangeSelect,
83
85
  onCopyClip,
84
86
  onPasteClip,
85
87
  onDuplicateClip,
@@ -167,10 +169,10 @@ export const Timeline = memo(function Timeline({
167
169
  selectedElementIds,
168
170
  );
169
171
  const expandedElementsRef = useRef(expandedElements);
170
- expandedElementsRef.current = expandedElements;
172
+ expandedElementsRef.current = expandedElements; // oxlint-disable-line react/refs -- event handlers read the latest elements
171
173
  const ppsRef = useRef(100);
172
174
  const durationRef = useRef(effectiveDuration);
173
- durationRef.current = effectiveDuration;
175
+ durationRef.current = effectiveDuration; // oxlint-disable-line react/refs -- event handlers read the latest duration
174
176
  const fitPpsRef = useRef(100);
175
177
  const {
176
178
  pinZoomBeforeEdit,
@@ -214,7 +216,6 @@ export const Timeline = memo(function Timeline({
214
216
  onMoveElements: pinnedOnMoveElements,
215
217
  });
216
218
  const onContextMenuClip = useClipContextMenu(onSelectElement, dismissGapMenu, setClipContextMenu);
217
-
218
219
  const {
219
220
  draggedClip,
220
221
  setDraggedClip,
@@ -241,7 +242,6 @@ export const Timeline = memo(function Timeline({
241
242
  refreshAfterLaneMove,
242
243
  sessionEpoch,
243
244
  });
244
-
245
245
  const assetDrop = useTimelineAssetDrop({
246
246
  scrollRef,
247
247
  ppsRef,
@@ -320,7 +320,6 @@ export const Timeline = memo(function Timeline({
320
320
  setKfContextMenu,
321
321
  toggleSelectedKeyframe,
322
322
  });
323
-
324
323
  const { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities } =
325
324
  useTimelineClipRenderWindow({
326
325
  tracks,
@@ -415,7 +414,8 @@ export const Timeline = memo(function Timeline({
415
414
  contentOrigin,
416
415
  sessionEpoch,
417
416
  });
418
- setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
417
+ usePublishRangeSelection(rangeSelection, onRangeSelect);
418
+ setRangeSelectionRef.current = setRangeSelection; // oxlint-disable-line react/refs -- stable ref consumed by useTimelineClipDrag
419
419
 
420
420
  useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () =>
421
421
  setRangeSelection(null),
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
+ import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
3
4
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
4
5
  import type { TimelineTheme } from "./timelineTheme";
5
6
  import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
@@ -23,6 +24,8 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverri
23
24
  renderClipOverlay?: (element: TimelineElement) => ReactNode;
24
25
  onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
25
26
  onSelectElement?: (element: TimelineElement | null) => void;
27
+ /** Notification only; null when cleared. The value lives in usePlayerStore.rangeSelection. */
28
+ onRangeSelect?: (range: TimelineTimeRange | null) => void;
26
29
  onCopyClip?: () => boolean;
27
30
  onPasteClip?: () => Promise<void>;
28
31
  onDuplicateClip?: () => Promise<boolean>;
@@ -0,0 +1,60 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { usePlayerStore } from "../store/playerStore";
6
+ import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
7
+ import type { TimelineRangeSelection } from "./timelineEditing";
8
+ import { usePublishRangeSelection } from "./usePublishRangeSelection";
9
+
10
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ function mount(onRangeSelect: (range: TimelineTimeRange | null) => void) {
13
+ const host = document.createElement("div");
14
+ const root = createRoot(host);
15
+ const Harness = ({ selection }: { selection: TimelineRangeSelection | null }) => {
16
+ usePublishRangeSelection(selection, onRangeSelect);
17
+ return null;
18
+ };
19
+ const render = (selection: TimelineRangeSelection | null) =>
20
+ act(() => root.render(<Harness selection={selection} />));
21
+ return { render, unmount: () => act(() => root.unmount()) };
22
+ }
23
+
24
+ const selection = (start: number, end: number): TimelineRangeSelection => ({
25
+ start,
26
+ end,
27
+ anchorX: 0,
28
+ anchorY: 0,
29
+ });
30
+
31
+ describe("usePublishRangeSelection", () => {
32
+ afterEach(() => usePlayerStore.setState({ rangeSelection: null }));
33
+
34
+ it("publishes an ordered range to the store and the host, and clears with null", () => {
35
+ const onRangeSelect = vi.fn();
36
+ const view = mount(onRangeSelect);
37
+
38
+ view.render(selection(3, 1));
39
+ expect(usePlayerStore.getState().rangeSelection).toEqual({ t0: 1, t1: 3 });
40
+ expect(onRangeSelect).toHaveBeenLastCalledWith({ t0: 1, t1: 3 });
41
+
42
+ view.render(null);
43
+ expect(usePlayerStore.getState().rangeSelection).toBeNull();
44
+ expect(onRangeSelect).toHaveBeenLastCalledWith(null);
45
+ view.unmount();
46
+ });
47
+
48
+ it("does not re-notify for an unchanged range and preserves a newer store value on unmount", () => {
49
+ const onRangeSelect = vi.fn();
50
+ const view = mount(onRangeSelect);
51
+
52
+ view.render(selection(1, 3));
53
+ view.render(selection(3, 1));
54
+ expect(onRangeSelect).toHaveBeenCalledTimes(1);
55
+
56
+ usePlayerStore.setState({ rangeSelection: { t0: 9, t1: 10 } });
57
+ view.unmount();
58
+ expect(usePlayerStore.getState().rangeSelection).toEqual({ t0: 9, t1: 10 });
59
+ });
60
+ });
@@ -0,0 +1,42 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { usePlayerStore } from "../store/playerStore";
3
+ import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
4
+ import type { TimelineRangeSelection } from "./timelineEditing";
5
+
6
+ /** Mirrors the gesture's range into the store and notifies the host. */
7
+ export function usePublishRangeSelection(
8
+ rangeSelection: TimelineRangeSelection | null,
9
+ onRangeSelect?: (range: TimelineTimeRange | null) => void,
10
+ ) {
11
+ const onRangeSelectRef = useRef(onRangeSelect);
12
+ const publishedRangeRef = useRef<TimelineTimeRange | null>(null);
13
+
14
+ useEffect(() => {
15
+ onRangeSelectRef.current = onRangeSelect;
16
+ }, [onRangeSelect]);
17
+
18
+ useEffect(() => {
19
+ const range = rangeSelection
20
+ ? {
21
+ t0: Math.min(rangeSelection.start, rangeSelection.end),
22
+ t1: Math.max(rangeSelection.start, rangeSelection.end),
23
+ }
24
+ : null;
25
+ const store = usePlayerStore.getState();
26
+ const current = store.rangeSelection;
27
+ if (current?.t0 === range?.t0 && current?.t1 === range?.t1) return;
28
+ publishedRangeRef.current = range;
29
+ usePlayerStore.setState({ rangeSelection: range });
30
+ onRangeSelectRef.current?.(range);
31
+ }, [rangeSelection]);
32
+
33
+ useEffect(
34
+ () => () => {
35
+ const current = usePlayerStore.getState().rangeSelection;
36
+ const published = publishedRangeRef.current;
37
+ if (current?.t0 !== published?.t0 || current?.t1 !== published?.t1) return;
38
+ usePlayerStore.setState({ rangeSelection: null });
39
+ },
40
+ [],
41
+ );
42
+ }
@@ -12,6 +12,7 @@ export { resolveIframe } from "./lib/timelineDOM";
12
12
  // Store
13
13
  export { usePlayerStore, liveTime } from "./store/playerStore";
14
14
  export type { SelectElementOptions, TimelineElement, ZoomMode } from "./store/playerStore";
15
+ export type { TimelineTimeRange } from "./store/rangeSelectionSlice";
15
16
 
16
17
  // Utils
17
18
  export { formatTime } from "./lib/time";
@@ -2,7 +2,6 @@ import { create } from "zustand";
2
2
  import { attachPlayerStoreDevHandle } from "./playerStoreDevHandle";
3
3
  import { nextSelectionSet, revealTargetsSelection } from "./playerStoreSelection";
4
4
  import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
5
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
5
  import type { BeatEditState } from "../../utils/beatEditing";
7
6
  import type { ClipManifestClip } from "../lib/playbackTypes";
8
7
  import {
@@ -11,7 +10,7 @@ import {
11
10
  type TimelineTimeDisplayMode,
12
11
  } from "../../utils/studioUiPreferences";
13
12
  import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
14
- import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
13
+ import { createKeyframeSlice, type KeyframeSlice } from "./keyframeSlice";
15
14
  import {
16
15
  createAutomationSelectionSlice,
17
16
  type AutomationSelectionSlice,
@@ -19,10 +18,12 @@ import {
19
18
  import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlice";
20
19
  import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
21
20
  import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
22
- import { createPlaybackReadinessSlice, resetPlaybackReadinessState } from "./readinessSlice";
23
-
21
+ import { createPlaybackReadinessSlice } from "./readinessSlice";
22
+ import { createRangeSelectionSlice, type RangeSelectionSlice } from "./rangeSelectionSlice";
23
+ import { createTimelineResetState } from "./timelineResetState";
24
24
  export type { KeyframeCacheEntry } from "./keyframeSlice";
25
25
  export { liveTime } from "./liveTime";
26
+ export { createTimelineResetState };
26
27
 
27
28
  import type {
28
29
  TimelineElement,
@@ -59,7 +60,8 @@ type PlayerStoreSlices = KeyframeSlice &
59
60
  AutomationSelectionSlice &
60
61
  ThumbnailSlice &
61
62
  EditingModeSlice &
62
- ReturnType<typeof createPlaybackReadinessSlice>;
63
+ ReturnType<typeof createPlaybackReadinessSlice> &
64
+ RangeSelectionSlice;
63
65
  interface PlayerState extends PlayerStoreSlices {
64
66
  isPlaying: boolean;
65
67
  currentTime: number;
@@ -250,52 +252,6 @@ interface BeatHistoryEntry {
250
252
  label: string;
251
253
  }
252
254
 
253
- export function createTimelineResetState() {
254
- return {
255
- isPlaying: false,
256
- currentTime: 0,
257
- duration: 0,
258
- ...resetPlaybackReadinessState(),
259
- beatDragging: false,
260
- elements: [],
261
- selectedElementId: null,
262
- zEditVersion: 0,
263
- inPoint: null,
264
- outPoint: null,
265
- activeTool: "select" as const,
266
- activeKeyframePct: null,
267
- motionPathArmed: false,
268
- motionPathCreateAvailable: false,
269
- selectedKeyframes: new Set<string>(),
270
- // Ephemeral like every other selection here. A range surviving a project
271
- // switch can match a same-keyed clip in the new project and redirect a
272
- // paste through `sel.elementKey === paste.elementKey` to a stale t0.
273
- automationSelection: null,
274
- expandedClipIds: new Set<string>(),
275
- // Per-composition: ids from comp A match nothing in B, silencing all of it.
276
- collapsedGroupIds: new Set<string>(),
277
- expandedLaneOwnerIds: new Set<string>(),
278
- focusedEaseSegment: null,
279
- revealedAudioFxTarget: null,
280
- selectedElementIds: new Set<string>(),
281
- requestedSeekTime: null,
282
- lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
283
- timelineFocus: null,
284
- keyframeCache: new Map<string, KeyframeCacheEntry>(),
285
- gsapAnimations: new Map<string, GsapAnimation[]>(),
286
- beatAnalysis: null,
287
- beatEdits: null,
288
- beatUndo: [],
289
- beatRedo: [],
290
- beatPersist: null,
291
- clipManifest: null,
292
- clipParentMap: new Map<string, string>(),
293
- topLevelIds: null,
294
- domClipChildren: [],
295
- subCompositionHostState: new Map<string, SubCompositionHostState>(),
296
- };
297
- }
298
-
299
255
  export const usePlayerStore = create<PlayerState>((set, get) => ({
300
256
  isPlaying: false,
301
257
  currentTime: 0,
@@ -328,6 +284,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
328
284
 
329
285
  ...createAutomationSelectionSlice(set),
330
286
  ...createEditingModeSlice(set),
287
+ ...createRangeSelectionSlice(),
331
288
  ...createPlaybackReadinessSlice(set),
332
289
 
333
290
  activeKeyframePct: null,
@@ -0,0 +1,13 @@
1
+ export interface TimelineTimeRange {
2
+ t0: number;
3
+ t1: number;
4
+ }
5
+
6
+ export interface RangeSelectionSlice {
7
+ /** Shift-drag ruler range in seconds, normalized so t0 <= t1. */
8
+ rangeSelection: TimelineTimeRange | null;
9
+ }
10
+
11
+ export function createRangeSelectionSlice(): RangeSelectionSlice {
12
+ return { rangeSelection: null };
13
+ }
@@ -0,0 +1,51 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import type { KeyframeCacheEntry } from "./keyframeSlice";
3
+ import { resetPlaybackReadinessState } from "./readinessSlice";
4
+ import type { SubCompositionHostState } from "./timelineElement";
5
+
6
+ export function createTimelineResetState() {
7
+ return {
8
+ isPlaying: false,
9
+ currentTime: 0,
10
+ duration: 0,
11
+ ...resetPlaybackReadinessState(),
12
+ beatDragging: false,
13
+ elements: [],
14
+ selectedElementId: null,
15
+ zEditVersion: 0,
16
+ inPoint: null,
17
+ outPoint: null,
18
+ rangeSelection: null,
19
+ activeTool: "select" as const,
20
+ activeKeyframePct: null,
21
+ motionPathArmed: false,
22
+ motionPathCreateAvailable: false,
23
+ selectedKeyframes: new Set<string>(),
24
+ // Ephemeral like every other selection here. A range surviving a project
25
+ // switch can match a same-keyed clip in the new project and redirect a
26
+ // paste through `sel.elementKey === paste.elementKey` to a stale t0.
27
+ automationSelection: null,
28
+ expandedClipIds: new Set<string>(),
29
+ // Per-composition: ids from comp A match nothing in B, silencing all of it.
30
+ collapsedGroupIds: new Set<string>(),
31
+ expandedLaneOwnerIds: new Set<string>(),
32
+ focusedEaseSegment: null,
33
+ revealedAudioFxTarget: null,
34
+ selectedElementIds: new Set<string>(),
35
+ requestedSeekTime: null,
36
+ lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
37
+ timelineFocus: null,
38
+ keyframeCache: new Map<string, KeyframeCacheEntry>(),
39
+ gsapAnimations: new Map<string, GsapAnimation[]>(),
40
+ beatAnalysis: null,
41
+ beatEdits: null,
42
+ beatUndo: [],
43
+ beatRedo: [],
44
+ beatPersist: null,
45
+ clipManifest: null,
46
+ clipParentMap: new Map<string, string>(),
47
+ topLevelIds: null,
48
+ domClipChildren: [],
49
+ subCompositionHostState: new Map<string, SubCompositionHostState>(),
50
+ };
51
+ }
@@ -165,9 +165,21 @@ export function buildStudioSaveFailureProperties(
165
165
  target_id: input.targetId ?? undefined,
166
166
  target_selector: input.targetSelector ?? undefined,
167
167
  target_source_file: input.targetSourceFile ?? undefined,
168
+ block_detail: gsapBlockDetail(input.error),
168
169
  };
169
170
  }
170
171
 
172
+ /**
173
+ * The specific cause behind a blocked GSAP edit. `error_message` only carries
174
+ * the coarse copy, and one of those strings covers six different situations,
175
+ * so the message alone cannot say which one a user hit.
176
+ */
177
+ function gsapBlockDetail(error: unknown): string | undefined {
178
+ if (!(error instanceof Error) || error.name !== "GsapEditBlockedError") return undefined;
179
+ const detail = (error as { detail?: unknown }).detail;
180
+ return typeof detail === "string" ? detail : undefined;
181
+ }
182
+
171
183
  export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
172
184
  trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
173
185
  }