@hyperframes/studio 0.8.57 → 0.8.59

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 (68) hide show
  1. package/dist/assets/{hyperframes-player-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
  2. package/dist/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
  3. package/dist/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
  4. package/dist/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
  5. package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  6. package/dist/index.d.ts +577 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +22537 -21600
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/components/TimelineToolbar.tsx +19 -0
  13. package/src/components/editor/MotionPathOverlay.test.tsx +0 -1
  14. package/src/components/editor/audioInspectorInput.test.ts +55 -0
  15. package/src/components/editor/audioInspectorInput.ts +55 -0
  16. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
  17. package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
  18. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
  19. package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
  20. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
  21. package/src/components/editor/useColorGradingController.ts +2 -6
  22. package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
  23. package/src/components/nle/AudioMeterStrip.tsx +213 -0
  24. package/src/components/nle/NLEContext.tsx +8 -2
  25. package/src/components/nle/NLEPreview.test.ts +1 -0
  26. package/src/components/nle/TimelinePane.tsx +29 -23
  27. package/src/components/nle/useCompositionStack.ts +1 -1
  28. package/src/contexts/TimelineEditContext.tsx +4 -0
  29. package/src/hooks/useCompositionDimensions.test.tsx +0 -1
  30. package/src/hooks/useSdkSession.lifecycle.test.tsx +28 -0
  31. package/src/hooks/useSdkSession.ts +31 -4
  32. package/src/hooks/useTimelineAssetDropOps.ts +9 -1
  33. package/src/index.ts +25 -1
  34. package/src/player/components/LayerDisclosureRow.tsx +3 -3
  35. package/src/player/components/Player.test.ts +34 -0
  36. package/src/player/components/Timeline.test.ts +22 -31
  37. package/src/player/components/Timeline.tsx +34 -7
  38. package/src/player/components/TimelineCanvas.tsx +10 -56
  39. package/src/player/components/TimelineClip.tsx +14 -1
  40. package/src/player/components/TimelineClipFades.test.tsx +171 -0
  41. package/src/player/components/TimelineClipFades.tsx +302 -0
  42. package/src/player/components/TimelineGroupHeader.tsx +4 -6
  43. package/src/player/components/TimelineOverlays.tsx +164 -165
  44. package/src/player/components/TimelineParts.test.tsx +101 -0
  45. package/src/player/components/TimelineParts.tsx +74 -0
  46. package/src/player/components/TimelinePartsParity.test.tsx +58 -0
  47. package/src/player/components/TimelineProvider.tsx +25 -0
  48. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  49. package/src/player/components/TimelineRulerPart.tsx +22 -0
  50. package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
  51. package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
  52. package/src/player/components/timelineProviderStateBuilders.ts +20 -0
  53. package/src/player/components/useTimelineProviderState.tsx +19 -12
  54. package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
  55. package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
  56. package/src/player/hooks/usePlayerHandle.ts +94 -0
  57. package/src/player/index.ts +7 -0
  58. package/src/player/lib/timelineElementHelpers.ts +20 -0
  59. package/src/player/store/timelineElement.ts +5 -0
  60. package/src/styles/theme.css +4 -0
  61. package/src/utils/audioMeterMath.test.ts +44 -0
  62. package/src/utils/audioMeterMath.ts +79 -0
  63. package/src/utils/audioMeterVisibility.ts +14 -0
  64. package/src/utils/studioHelpers.ts +21 -0
  65. package/src/utils/studioUiPreferences.test.ts +10 -0
  66. package/src/utils/studioUiPreferences.ts +5 -0
  67. package/src/utils/timelineAssetDrop.test.ts +32 -0
  68. package/src/utils/timelineAssetDrop.ts +6 -1
@@ -0,0 +1,101 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { TimelineContextValue } from "./TimelineProvider";
5
+ import { TimelineContextProvider, useTimelineContext } from "./TimelineProvider";
6
+ import { TimelineClipMenu, TimelineLanes, TimelinePlayhead, TimelineRuler } from "./TimelineParts";
7
+ import { createHappyDomRootHarness } from "./testRootHarness";
8
+
9
+ vi.mock("./TimelineRuler", () => ({
10
+ TimelineRuler: () => <div data-testid="timeline-ruler" />,
11
+ }));
12
+ vi.mock("./TimelineLanes", () => ({
13
+ TimelineLanes: ({ renderClipContent }: { renderClipContent?: unknown }) => {
14
+ const { state } = useTimelineContext();
15
+ return (
16
+ <div data-testid="timeline-clip">
17
+ {state.elements[0]?.id}
18
+ {renderClipContent ? <span data-testid="lane-render-bridge" /> : null}
19
+ </div>
20
+ );
21
+ },
22
+ }));
23
+
24
+ const { mount } = createHappyDomRootHarness();
25
+
26
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
27
+
28
+ afterEach(() => {
29
+ document.body.innerHTML = "";
30
+ });
31
+
32
+ describe("Timeline parts composition", () => {
33
+ it("reads a shared provider context when composed without Timeline or Frame", () => {
34
+ const host = document.createElement("div");
35
+ document.body.append(host);
36
+ const root = mount(host);
37
+ const onSplitElement = vi.fn();
38
+ const element = { id: "clip-a", key: "clip-a", tag: "video", start: 0, duration: 2, track: 0 };
39
+ const value = {
40
+ state: {
41
+ timelineReady: true,
42
+ elements: [element],
43
+ selectedElementId: "clip-a",
44
+ sessionEpoch: 1,
45
+ keyframeCache: new Map(),
46
+ canvas: { isScrubbing: false } as TimelineContextValue["state"]["canvas"],
47
+ overlays: {
48
+ elements: [element],
49
+ elementsRef: { current: [element] },
50
+ theme: {} as TimelineContextValue["state"]["overlays"]["theme"],
51
+ showShortcutHint: false,
52
+ showPopover: false,
53
+ rangeSelection: null,
54
+ setShowPopover: vi.fn(),
55
+ setRangeSelection: vi.fn(),
56
+ kfContextMenu: null,
57
+ setKfContextMenu: vi.fn(),
58
+ onDeleteKeyframe: vi.fn(),
59
+ onDeleteAllKeyframes: vi.fn(),
60
+ onMoveKeyframeToPlayhead: vi.fn(),
61
+ clipContextMenu: { x: 10, y: 10, element, sessionEpoch: 1 },
62
+ setClipContextMenu: vi.fn(),
63
+ currentTime: 1,
64
+ onSplitElement,
65
+ pinZoomBeforeEdit: vi.fn(),
66
+ gapContextMenu: null,
67
+ onDismissGapContextMenu: vi.fn(),
68
+ onCloseTrackGap: vi.fn(),
69
+ onCloseAllTrackGaps: vi.fn(),
70
+ onHoverGapAction: vi.fn(),
71
+ },
72
+ },
73
+ actions: {
74
+ renderClipContent: vi.fn(),
75
+ renderClipOverlay: undefined,
76
+ setFocusedEaseSegment: vi.fn(),
77
+ },
78
+ meta: { razorGuide: null } as TimelineContextValue["meta"],
79
+ } satisfies TimelineContextValue;
80
+
81
+ try {
82
+ act(() => {
83
+ root.render(
84
+ <TimelineContextProvider value={value}>
85
+ <TimelineRuler />
86
+ <TimelineLanes />
87
+ <TimelinePlayhead />
88
+ <TimelineClipMenu />
89
+ </TimelineContextProvider>,
90
+ );
91
+ });
92
+
93
+ expect(host.querySelector("[data-testid='timeline-ruler']")).not.toBeNull();
94
+ expect(host.querySelector("[data-testid='timeline-clip']")?.textContent).toBe("clip-a");
95
+ expect(host.querySelector("[data-testid='lane-render-bridge']")).not.toBeNull();
96
+ expect(document.querySelector("[role='menu'][aria-label='Clip actions']")).not.toBeNull();
97
+ } finally {
98
+ act(() => root.unmount());
99
+ }
100
+ });
101
+ });
@@ -0,0 +1,74 @@
1
+ import { TimelineCanvas as TimelineCanvasImpl } from "./TimelineCanvas";
2
+ import {
3
+ TimelineClipMenuOverlay as TimelineClipMenuImpl,
4
+ TimelineEditPopoverOverlay as TimelineEditPopoverImpl,
5
+ TimelineGapMenuOverlay as TimelineGapMenuImpl,
6
+ TimelineKeyframeMenuOverlay as TimelineKeyframeMenuImpl,
7
+ TimelineShortcutHintOverlay as TimelineShortcutHintImpl,
8
+ } from "./TimelineOverlays";
9
+ import { TimelineLanes as TimelineLanesImpl } from "./TimelineLanes";
10
+ import { PlayheadIndicator } from "./PlayheadIndicator";
11
+ import { useTimelineContext } from "./TimelineProvider";
12
+ import { TimelineEmptyState } from "./TimelineEmptyState";
13
+ import { TimelineRulerPart } from "./TimelineRulerPart";
14
+
15
+ /** The provider-backed canvas shell. */
16
+ export function TimelineFrame() {
17
+ return <TimelineCanvasImpl />;
18
+ }
19
+
20
+ /** The provider-backed ruler. */
21
+ export const TimelineRuler = TimelineRulerPart;
22
+
23
+ /**
24
+ * The provider-backed lane renderer. TimelineFrame remains the composed Studio
25
+ * canvas; this part is exported for hosts that own the canvas arrangement.
26
+ */
27
+ export function TimelineLanes() {
28
+ const { state, actions } = useTimelineContext();
29
+ const props = state.canvas;
30
+ return (
31
+ <TimelineLanesImpl
32
+ {...props}
33
+ renderClipContent={actions.renderClipContent}
34
+ renderClipOverlay={actions.renderClipOverlay}
35
+ />
36
+ );
37
+ }
38
+
39
+ /** The provider-backed playhead indicator. */
40
+ export function TimelinePlayhead() {
41
+ const { state } = useTimelineContext();
42
+ const props = state.canvas;
43
+ return <PlayheadIndicator scrubbing={props.isScrubbing} />;
44
+ }
45
+
46
+ /** The provider-backed razor guide. */
47
+ export function TimelineRazorGuide() {
48
+ const { meta } = useTimelineContext();
49
+ return meta.razorGuide;
50
+ }
51
+
52
+ /** The composed overlay surface used by the Studio variant. */
53
+ export { TimelineOverlays } from "./TimelineOverlays";
54
+
55
+ export function TimelineEmptyStatePart() {
56
+ const { meta } = useTimelineContext();
57
+ return <TimelineEmptyState {...meta.emptyState} />;
58
+ }
59
+
60
+ export function TimelineShortcutHint() {
61
+ return <TimelineShortcutHintImpl />;
62
+ }
63
+ export function TimelineEditPopover() {
64
+ return <TimelineEditPopoverImpl />;
65
+ }
66
+ export function TimelineClipMenu() {
67
+ return <TimelineClipMenuImpl />;
68
+ }
69
+ export function TimelineKeyframeMenu() {
70
+ return <TimelineKeyframeMenuImpl />;
71
+ }
72
+ export function TimelineGapMenu() {
73
+ return <TimelineGapMenuImpl />;
74
+ }
@@ -0,0 +1,58 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { Timeline } from "./Timeline";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import { useTimelineContext } from "./TimelineProvider";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ usePlayerStore.getState().reset();
15
+ });
16
+
17
+ function TimelinePartsVariant() {
18
+ const { meta } = useTimelineContext();
19
+ return React.createElement(
20
+ "div",
21
+ meta.containerProps,
22
+ React.createElement(
23
+ "div",
24
+ meta.viewportProps,
25
+ React.createElement(Timeline.Frame),
26
+ React.createElement(Timeline.RazorGuide),
27
+ ),
28
+ React.createElement(Timeline.Overlays),
29
+ );
30
+ }
31
+
32
+ describe("Timeline provider boundary", () => {
33
+ it("keeps the composed Timeline markup equal to its provider parts", () => {
34
+ usePlayerStore.setState({
35
+ duration: 4,
36
+ timelineReady: true,
37
+ elements: [{ id: "parity-clip", tag: "div", start: 0, duration: 2, track: 0 }],
38
+ });
39
+ const composedHost = document.createElement("div");
40
+ const partsHost = document.createElement("div");
41
+ document.body.append(composedHost, partsHost);
42
+ const composedRoot = createRoot(composedHost);
43
+ const partsRoot = createRoot(partsHost);
44
+ act(() => {
45
+ composedRoot.render(React.createElement(Timeline));
46
+ partsRoot.render(
47
+ React.createElement(Timeline.Provider, null, React.createElement(TimelinePartsVariant)),
48
+ );
49
+ });
50
+ const normalizeMarkup = (markup: string) =>
51
+ markup.replaceAll(/timeline-lanes_[^"]+/g, "timeline-lanes");
52
+ expect(normalizeMarkup(partsHost.innerHTML)).toBe(normalizeMarkup(composedHost.innerHTML));
53
+ act(() => {
54
+ composedRoot.unmount();
55
+ partsRoot.unmount();
56
+ });
57
+ });
58
+ });
@@ -17,6 +17,8 @@ import type { Rect } from "../../utils/marqueeGeometry";
17
17
  import type { ResizingClipState } from "./useTimelineClipDrag";
18
18
  import type { TimelineLaneBaseProps } from "./timelineLaneProps";
19
19
  import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
20
+ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
21
+ import type { TimelineSnapTarget } from "./timelineSnapping";
20
22
  import type { TimelineElement } from "../store/playerStore";
21
23
  import type { KeyframeCacheEntry } from "../store/keyframeSlice";
22
24
  import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
@@ -24,6 +26,10 @@ import type { TimelineTheme } from "./timelineTheme";
24
26
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
25
27
  import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
26
28
  import { useTimelineProviderState } from "./useTimelineProviderState";
29
+ import {
30
+ TimelineEditProvider,
31
+ useTimelineEditContextValue,
32
+ } from "../../contexts/TimelineEditContext";
27
33
 
28
34
  export type TimelineCanvasState = Omit<
29
35
  TimelineLaneBaseProps,
@@ -51,6 +57,13 @@ export type TimelineCanvasState = Omit<
51
57
  beatDragging: boolean;
52
58
  onResizeElement: TimelineEditCallbacks["onResizeElement"];
53
59
  onMoveElement: TimelineEditCallbacks["onMoveElement"];
60
+ draggedElement: TimelineElement | null;
61
+ snapGuide: TimelineSnapTarget | null;
62
+ multiDragPreview: MultiDragPreviewInput | null;
63
+ onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
64
+ onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
65
+ onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
66
+ onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
54
67
  };
55
68
 
56
69
  export interface ClipContextMenuState {
@@ -166,6 +179,18 @@ export interface TimelineContextValue {
166
179
  const TimelineContext = createContext<TimelineContextValue | null>(null);
167
180
 
168
181
  export function TimelineProvider({ children, ...props }: TimelineProps & { children: ReactNode }) {
182
+ const editContext = useTimelineEditContextValue();
183
+ if (!editContext) {
184
+ return (
185
+ <TimelineEditProvider value={props}>
186
+ <TimelineProviderState {...props}>{children}</TimelineProviderState>
187
+ </TimelineEditProvider>
188
+ );
189
+ }
190
+ return <TimelineProviderState {...props}>{children}</TimelineProviderState>;
191
+ }
192
+
193
+ function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) {
169
194
  const value = useTimelineProviderState(props);
170
195
  return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
171
196
  }
@@ -50,7 +50,7 @@ export function useTimelineRazorInteraction({
50
50
  return { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer };
51
51
  }
52
52
 
53
- export function TimelineRazorGuide({ x }: { x: number }) {
53
+ export function TimelineRazorGuideOverlay({ x }: { x: number }) {
54
54
  return (
55
55
  <div
56
56
  className="absolute top-0 bottom-0 pointer-events-none z-10"
@@ -0,0 +1,22 @@
1
+ import { TimelineRuler as TimelineRulerImpl } from "./TimelineRuler";
2
+ import { useTimelineContext } from "./TimelineProvider";
3
+
4
+ export function TimelineRulerPart() {
5
+ const { state } = useTimelineContext();
6
+ const props = state.canvas;
7
+ return (
8
+ <TimelineRulerImpl
9
+ major={props.major}
10
+ minor={props.minor}
11
+ pps={props.pps}
12
+ trackContentWidth={props.trackContentWidth}
13
+ totalH={props.totalH}
14
+ effectiveDuration={props.effectiveDuration}
15
+ majorTickInterval={props.majorTickInterval}
16
+ theme={props.theme}
17
+ beatAnalysis={props.beatAnalysis}
18
+ contentOrigin={props.contentOrigin}
19
+ renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
20
+ />
21
+ );
22
+ }
@@ -136,4 +136,14 @@ describe("PlainTrackHeader", () => {
136
136
  expect(labelOf(view.host)).toBe("Hide track 1");
137
137
  view.unmount();
138
138
  });
139
+
140
+ it("truncates long names and keeps the full name in the tooltip", () => {
141
+ const name = "A-very-long-unbroken-clip-name-for-the-timeline";
142
+ const view = renderHeader({ trackLabel: name });
143
+ const label = view.host.querySelector("span[title]");
144
+ expect(label?.className).toContain("truncate");
145
+ expect(label?.className).not.toContain("wrap-break-word");
146
+ expect(label?.getAttribute("title")).toBe(name);
147
+ view.unmount();
148
+ });
139
149
  });
@@ -93,14 +93,11 @@ export function PlainTrackHeader({
93
93
  )}
94
94
  {/* No `flex-1`: the name takes only the width it needs, so the clip
95
95
  count sits against it rather than being pushed out to meet the
96
- controls. The slack goes to the `ml-auto` group below instead.
97
-
98
- Wraps rather than truncating: a truncated name needs a hover to be
99
- read at all, and a tooltip is no use to a name you are scanning a
100
- column of. `wrap-break-word` so a long single token breaks instead of
101
- forcing the column wider. */}
96
+ controls. The slack goes to the `ml-auto` group below instead. */}
102
97
  {showTrackLabel && (
103
- <span className="min-w-0 wrap-break-word text-[11px] leading-tight">{trackLabel}</span>
98
+ <span title={trackLabel} className="min-w-0 truncate text-[11px] leading-tight">
99
+ {trackLabel}
100
+ </span>
104
101
  )}
105
102
  {showTrackLabel && <TrackClipCount clipCount={clipCount} />}
106
103
  {/* `ml-auto` is what anchors the group right: it absorbs the slack the
@@ -5,6 +5,26 @@ import type {
5
5
  } from "./TimelineProvider";
6
6
  import type { ResizingClipState } from "./useTimelineClipDrag";
7
7
  import type { TimelineLaneBaseProps } from "./timelineLaneProps";
8
+ import type { DraggedClipState } from "./timelineClipDragTypes";
9
+ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
10
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
11
+
12
+ export function resolveMultiDragPreview(
13
+ draggedClip: DraggedClipState | null,
14
+ selectedKeys: ReadonlySet<string>,
15
+ ): MultiDragPreviewInput | null {
16
+ // The dragged clip is a free ghost; selected companions follow the same
17
+ // clamped delta so the formation stays rigid at the lane boundary.
18
+ if (!draggedClip?.started) return null;
19
+ const draggedKey = getTimelineElementIdentity(draggedClip.element);
20
+ return {
21
+ dragStarted: true,
22
+ draggedKey,
23
+ draggedOriginStart: draggedClip.element.start,
24
+ draggedPreviewStart: draggedClip.previewStart,
25
+ selectedKeys,
26
+ };
27
+ }
8
28
 
9
29
  export function resolveResizingElementIds(
10
30
  resizingClip: ResizingClipState | null,
@@ -10,6 +10,7 @@ import { useTimelineClipDrag } from "./useTimelineClipDrag";
10
10
  import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvider";
11
11
  import {
12
12
  buildTimelineMeta,
13
+ resolveMultiDragPreview,
13
14
  resolveRenderClipContent,
14
15
  resolveResizingElementIds,
15
16
  shouldIgnoreTimelinePointerDown,
@@ -30,7 +31,7 @@ import {
30
31
  } from "./useTimelineTrackLayout";
31
32
  import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
32
33
  import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
33
- import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
34
+ import { TimelineRazorGuideOverlay, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
34
35
  import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
35
36
  import {
36
37
  getEffectiveTimelineDuration,
@@ -44,6 +45,8 @@ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
44
45
  import { useTimelineActiveClips } from "./useTimelineActiveClips";
45
46
  import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
46
47
  import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
48
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
49
+ import { resolveSnapGuide } from "./timelineSnapping";
47
50
  export function useTimelineProviderState({
48
51
  onSeek,
49
52
  onDrillDown,
@@ -90,10 +93,10 @@ export function useTimelineProviderState({
90
93
  onSplitElement: onSplitElementOverride,
91
94
  });
92
95
  const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
96
+ const editContext = useTimelineEditContextOptional();
93
97
  const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
94
98
  useMusicBeatAnalysis();
95
- const rawElements = usePlayerStore((s) => s.elements);
96
- const timelineElements = rawElements;
99
+ const timelineElements = usePlayerStore((s) => s.elements);
97
100
  const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
98
101
  const duration = usePlayerStore((s) => s.duration);
99
102
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
@@ -108,7 +111,6 @@ export function useTimelineProviderState({
108
111
  );
109
112
  // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
110
113
  const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
111
- const contentGutter = labelMode ? GUTTER : 0;
112
114
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
113
115
  const currentTime = usePlayerStore((s) => s.currentTime);
114
116
  const beatDragging = usePlayerStore((s) => s.beatDragging);
@@ -130,8 +132,8 @@ export function useTimelineProviderState({
130
132
  }, []);
131
133
  const lastScrollLeftRef = useRef(0);
132
134
  const effectiveDuration = useMemo(
133
- () => getEffectiveTimelineDuration(duration, rawElements),
134
- [duration, rawElements],
135
+ () => getEffectiveTimelineDuration(duration, timelineElements),
136
+ [duration, timelineElements],
135
137
  );
136
138
  const keyframeCache = usePlayerStore((s) => s.keyframeCache);
137
139
  useAutoExpandKeyframedClips(gsapAnimations);
@@ -406,7 +408,6 @@ export function useTimelineProviderState({
406
408
  handlePointerCancel,
407
409
  } = overlaysProps;
408
410
  const { rangeSelection, setRangeSelection } = overlays;
409
-
410
411
  const laneGapStrips = useTimelineGapHighlights({
411
412
  gapHighlight,
412
413
  tracks,
@@ -416,25 +417,24 @@ export function useTimelineProviderState({
416
417
  dragActive: draggedClip?.started === true || resizingClip != null,
417
418
  displayDuration,
418
419
  });
419
-
420
420
  const { major, minor, majorTickInterval } = useTimelineTicks(
421
421
  displayDuration,
422
422
  pps,
423
423
  timeDisplayMode,
424
424
  timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
425
425
  );
426
-
427
426
  const getPreviewElement = useCallback(
428
427
  (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
429
428
  [resizingClip],
430
429
  );
431
-
430
+ const draggedElement = draggedClip?.element ?? null;
431
+ const multiDragPreview = resolveMultiDragPreview(draggedClip, selectedElementIds);
432
432
  const canvasProps = {
433
433
  major,
434
434
  minor,
435
435
  pps,
436
436
  contentOrigin,
437
- contentGutter,
437
+ contentGutter: labelMode ? GUTTER : 0,
438
438
  trackContentWidth: displayContentWidth,
439
439
  totalH: displayLayout.totalH,
440
440
  effectiveDuration,
@@ -501,6 +501,13 @@ export function useTimelineProviderState({
501
501
  onResizeElement,
502
502
  onMoveElement,
503
503
  beatDragging,
504
+ draggedElement,
505
+ snapGuide: resolveSnapGuide(draggedClip, resizingClip),
506
+ multiDragPreview,
507
+ onToggleTrackHidden: editContext.onToggleTrackHidden,
508
+ onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
509
+ onRazorSplit: editContext.onRazorSplit,
510
+ onRazorSplitAll: editContext.onRazorSplitAll,
504
511
  };
505
512
  const timelineRenderClipContent = resolveRenderClipContent(
506
513
  timelineFocus.rowVirtualizationActive,
@@ -559,7 +566,7 @@ export function useTimelineProviderState({
559
566
  labelColumnWidth: LABEL_COL_W,
560
567
  razorGuide:
561
568
  activeTool === "razor" && razorGuideX !== null ? (
562
- <TimelineRazorGuide x={razorGuideX} />
569
+ <TimelineRazorGuideOverlay x={razorGuideX} />
563
570
  ) : null,
564
571
  });
565
572
  const contextValue: TimelineContextValue = {
@@ -0,0 +1,41 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { resolvePlaybackAdapter } from "./playbackAdapterResolution";
5
+
6
+ function timeline(duration: number) {
7
+ return {
8
+ duration: vi.fn(() => duration),
9
+ time: vi.fn(() => 0),
10
+ seek: vi.fn(),
11
+ play: vi.fn(),
12
+ pause: vi.fn(),
13
+ isActive: vi.fn(() => false),
14
+ };
15
+ }
16
+
17
+ describe("resolvePlaybackAdapter", () => {
18
+ it("uses the __timelines key matching the document root, not the first key", () => {
19
+ const first = timeline(12);
20
+ const second = timeline(12);
21
+ const third = timeline(12);
22
+ const iframe = document.createElement("iframe");
23
+ const doc = document.implementation.createHTMLDocument("preview");
24
+ doc.body.innerHTML = '<div data-composition-id="second" data-duration="10"></div>';
25
+ Object.defineProperty(iframe, "contentDocument", { configurable: true, value: doc });
26
+ const win = {
27
+ __timelines: { first, second, third },
28
+ } as never;
29
+
30
+ const adapter = resolvePlaybackAdapter(iframe, win, {
31
+ cache: { current: null },
32
+ warned: { current: false },
33
+ });
34
+
35
+ expect(adapter).not.toBeNull();
36
+ adapter?.seek(7);
37
+ expect(second.seek).toHaveBeenCalledWith(7);
38
+ expect(first.seek).not.toHaveBeenCalled();
39
+ expect(third.seek).not.toHaveBeenCalled();
40
+ });
41
+ });