@hyperframes/studio 0.8.90 → 0.8.92

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 (102) hide show
  1. package/dist/assets/{hyperframes-player-DDjER_ii.js → hyperframes-player-DKJPDHUp.js} +1 -1
  2. package/dist/assets/{index-BKE3_N4v.js → index-C5_j5-3u.js} +178 -178
  3. package/dist/assets/{index-5SFeUXP8.js → index-CHZ4kFWm.js} +1 -1
  4. package/dist/assets/{index-CVsCxnlz.js → index-DpzpqJ_j.js} +1 -1
  5. package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
  6. package/dist/chunk-QPSOTK6M.js.map +1 -0
  7. package/dist/index.d.ts +18 -14
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +9190 -8916
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/components/TimelineToolbar.test.tsx +12 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +5 -1
  15. package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
  16. package/src/components/editor/elementPositionOffset.test.ts +62 -0
  17. package/src/components/editor/elementPositionOffset.ts +64 -0
  18. package/src/components/editor/motionPathCommit.test.ts +25 -5
  19. package/src/components/editor/motionPathSelection.ts +10 -3
  20. package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
  21. package/src/components/editor/useLayerRevealOverride.ts +2 -1
  22. package/src/components/nle/NLEContext.tsx +1 -1
  23. package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
  24. package/src/hooks/domEditPersistFailure.ts +8 -6
  25. package/src/hooks/elementOffsetStager.ts +65 -0
  26. package/src/hooks/gestureTransaction.ts +4 -2
  27. package/src/hooks/gsapDragCommit.ts +1 -0
  28. package/src/hooks/gsapEditOutcome.ts +2 -1
  29. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +50 -14
  31. package/src/hooks/gsapShared.test.ts +1 -1
  32. package/src/hooks/gsapShared.ts +16 -12
  33. package/src/hooks/gsapTweenReach.ts +30 -0
  34. package/src/hooks/timelineEditingHelpers.ts +2 -1
  35. package/src/hooks/useClipboard.source.test.ts +58 -0
  36. package/src/hooks/useClipboard.ts +12 -3
  37. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  38. package/src/hooks/useDomEditCommits.ts +2 -0
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
  40. package/src/hooks/useDomEditSession.ts +2 -0
  41. package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
  42. package/src/hooks/useDomGeometryCommit.ts +42 -1
  43. package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
  44. package/src/hooks/useDomGeometryCommits.ts +14 -1
  45. package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
  46. package/src/hooks/useGsapAwareEditing.ts +44 -7
  47. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
  48. package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
  49. package/src/hooks/useRazorSplit.test.ts +5 -5
  50. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  51. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  52. package/src/hooks/useTimelineEditing.test.tsx +384 -0
  53. package/src/hooks/useTimelineEditing.ts +12 -12
  54. package/src/hooks/useTimelineGroupEditing.ts +16 -7
  55. package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
  56. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  57. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  58. package/src/player/components/TimelineProvider.tsx +1 -0
  59. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  60. package/src/player/components/TimelineTypes.ts +1 -1
  61. package/src/player/components/timelineClipDragCommit.test.ts +32 -66
  62. package/src/player/components/timelineClipDragCommit.ts +19 -37
  63. package/src/player/components/timelineClipDragPreview.test.ts +283 -24
  64. package/src/player/components/timelineClipDragPreview.ts +51 -83
  65. package/src/player/components/timelineClipDragTypes.ts +3 -8
  66. package/src/player/components/timelineCollision.test.ts +100 -302
  67. package/src/player/components/timelineCollision.ts +73 -160
  68. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  69. package/src/player/components/timelineDragDrop.ts +7 -9
  70. package/src/player/components/timelineDragLanding.ts +0 -49
  71. package/src/player/components/timelineEditing.ts +2 -1
  72. package/src/player/components/timelineGroupEditing.ts +7 -2
  73. package/src/player/components/timelineLayout.test.ts +0 -25
  74. package/src/player/components/timelineLayout.ts +0 -16
  75. package/src/player/components/timelineMultiDragPreview.test.ts +34 -23
  76. package/src/player/components/timelineMultiDragPreview.ts +26 -25
  77. package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
  78. package/src/player/components/timelineProviderStateBuilders.ts +14 -6
  79. package/src/player/components/timelineShellMeta.tsx +99 -0
  80. package/src/player/components/timelineZMirror.test.ts +2 -2
  81. package/src/player/components/timelineZMirror.ts +1 -1
  82. package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
  83. package/src/player/components/useTimelineClipDrag.ts +3 -2
  84. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  85. package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
  86. package/src/player/components/useTimelinePlayhead.ts +56 -27
  87. package/src/player/components/useTimelineProviderState.tsx +29 -66
  88. package/src/player/components/useTimelineZoom.ts +9 -1
  89. package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -4
  91. package/src/player/lib/timelineDOM.test.ts +79 -0
  92. package/src/player/lib/timelineDOM.ts +16 -7
  93. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  94. package/src/player/lib/timelineIframeHelpers.ts +12 -3
  95. package/src/player/store/liveTime.ts +6 -0
  96. package/src/player/store/playerStore.ts +2 -0
  97. package/src/player/store/timelineElement.ts +28 -3
  98. package/src/utils/razorSplitTransaction.test.ts +1 -1
  99. package/src/utils/razorSplitTransaction.ts +3 -3
  100. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  101. package/src/utils/timelineDropTrackInsert.ts +36 -5
  102. package/dist/chunk-KU4F7QZ3.js.map +0 -1
@@ -12,6 +12,22 @@ import {
12
12
  import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
13
13
  import { applyTimelineHorizontalAutoScrollStep } from "./timelineEditing";
14
14
 
15
+ function revealPlayheadScrollLeft(
16
+ scroll: HTMLDivElement,
17
+ playheadX: number,
18
+ contentOrigin: number,
19
+ from = scroll.scrollLeft,
20
+ ): number {
21
+ if (playheadX >= from + contentOrigin && playheadX <= from + scroll.clientWidth) return from;
22
+ return getTimelinePlaybackFollowScrollLeft({
23
+ playheadX,
24
+ currentScrollLeft: from,
25
+ viewportWidth: scroll.clientWidth,
26
+ contentOrigin,
27
+ maxScrollLeft: scroll.scrollWidth - scroll.clientWidth,
28
+ });
29
+ }
30
+
15
31
  interface UseTimelinePlayheadInput {
16
32
  playheadRef: React.RefObject<HTMLDivElement | null>;
17
33
  scrollRef: React.RefObject<HTMLDivElement | null>;
@@ -58,10 +74,13 @@ export function useTimelinePlayhead({
58
74
  }: UseTimelinePlayheadInput) {
59
75
  const dragScrollRaf = useRef(0);
60
76
  const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
61
- // Center-anchored magnify: keep the time at the viewport center fixed when
62
- // the zoom level (pps) changes via the toolbar / slider. The pinch handler
63
- // anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`.
77
+ // A toolbar zoom keeps the playhead in place; a resize keeps the centre, or 00:00 at the start.
78
+ // The pinch handler anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`.
64
79
  const previousAnchorPpsRef = useRef(pps);
80
+ const userZoomCount = usePlayerStore((s) => s.userZoomCount);
81
+ const previousZoomCountRef = useRef(userZoomCount);
82
+ const lastLiveTimeRef = useRef(usePlayerStore.getState().currentTime);
83
+ const lastSeekCountRef = useRef(liveTime.seekCount());
65
84
  const skipCenterAnchorRef = useRef(false);
66
85
  const contentOriginRef = useRef(contentOrigin);
67
86
  contentOriginRef.current = contentOrigin;
@@ -70,15 +89,22 @@ export function useTimelinePlayhead({
70
89
  const scroll = scrollRef.current;
71
90
  const prevPps = previousAnchorPpsRef.current;
72
91
  previousAnchorPpsRef.current = pps;
92
+ const prevZoomCount = previousZoomCountRef.current;
93
+ previousZoomCountRef.current = userZoomCount;
73
94
  // Always consume the skip flag, even when pps didn't change — otherwise a
74
95
  // pinch that produced no pps change (already at the zoom clamp) would strand
75
96
  // it true and the next toolbar zoom would wrongly skip center-anchoring.
76
97
  const skip = skipCenterAnchorRef.current;
77
98
  skipCenterAnchorRef.current = false;
78
- // A view at the start stays there: a resize or a host zoom must not hide 00:00.
79
- if (!scroll || pps === prevPps || skip || scroll.scrollLeft < 1) return;
99
+ if (!scroll || pps === prevPps || skip) return;
100
+ const zoomed = userZoomCount !== prevZoomCount;
101
+ if (!zoomed && scroll.scrollLeft < 1) return;
102
+ const time = Math.max(0, lastLiveTimeRef.current);
103
+ const playheadX = contentOrigin + time * prevPps;
104
+ const onScreen =
105
+ revealPlayheadScrollLeft(scroll, playheadX, contentOrigin) === scroll.scrollLeft;
80
106
  const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
81
- pointerX: scroll.clientWidth / 2,
107
+ pointerX: zoomed && onScreen ? playheadX - scroll.scrollLeft : scroll.clientWidth / 2,
82
108
  currentScrollLeft: scroll.scrollLeft,
83
109
  contentOrigin,
84
110
  currentPixelsPerSecond: prevPps,
@@ -86,8 +112,11 @@ export function useTimelinePlayhead({
86
112
  duration: durationRef.current,
87
113
  });
88
114
  const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
89
- scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
90
- }, [pps, scrollRef, durationRef, contentOrigin]);
115
+ const anchored = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
116
+ scroll.scrollLeft = zoomed
117
+ ? revealPlayheadScrollLeft(scroll, contentOrigin + time * pps, contentOrigin, anchored)
118
+ : anchored;
119
+ }, [pps, userZoomCount, scrollRef, durationRef, contentOrigin]);
91
120
 
92
121
  const syncPlayheadPosition = useCallback(
93
122
  (time: number) => {
@@ -127,7 +156,6 @@ export function useTimelinePlayhead({
127
156
  }, [zoomMode, scrollRef]);
128
157
 
129
158
  useMountEffect(() => {
130
- let lastLiveTime = usePlayerStore.getState().currentTime;
131
159
  const place = (t: number, atRest: boolean) => {
132
160
  if (!playheadRef.current || durationRef.current <= 0) return false;
133
161
  playheadRef.current.style.transform = getTimelinePlayheadTransform(
@@ -138,29 +166,30 @@ export function useTimelinePlayhead({
138
166
  );
139
167
  return true;
140
168
  };
169
+ const dragging = () => isDragging.current || usePlayerStore.getState().beatDragging;
141
170
  const unsubPlaying = usePlayerStore.subscribe((state, prev) => {
142
- if (prev.isPlaying && !state.isPlaying) place(lastLiveTime, true);
171
+ if (prev.isPlaying && !state.isPlaying) place(lastLiveTimeRef.current, true);
143
172
  });
173
+ lastSeekCountRef.current = liveTime.seekCount();
144
174
  const unsub = liveTime.subscribe((t) => {
145
- lastLiveTime = t;
146
- if (!place(t, !usePlayerStore.getState().isPlaying)) return;
175
+ const sought = liveTime.seekCount() !== lastSeekCountRef.current;
176
+ lastSeekCountRef.current = liveTime.seekCount();
177
+ lastLiveTimeRef.current = t;
178
+ const playing = usePlayerStore.getState().isPlaying;
179
+ if (!place(t, !playing)) return;
147
180
  const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current;
148
181
  const scroll = scrollRef.current;
149
- if (
150
- !scroll ||
151
- !usePlayerStore.getState().isPlaying ||
152
- isDragging.current ||
153
- zoomModeRef.current === "fit"
154
- ) {
155
- return;
156
- }
157
- const nextScrollLeft = getTimelinePlaybackFollowScrollLeft({
158
- playheadX,
159
- currentScrollLeft: scroll.scrollLeft,
160
- viewportWidth: scroll.clientWidth,
161
- contentOrigin: contentOriginRef.current,
162
- maxScrollLeft: scroll.scrollWidth - scroll.clientWidth,
163
- });
182
+ // Paused, only a seek scrolls: a reload's republish, frame-rounded, must not undo a person's scroll.
183
+ if (!scroll || dragging() || zoomModeRef.current === "fit" || (!playing && !sought)) return;
184
+ const nextScrollLeft = playing
185
+ ? getTimelinePlaybackFollowScrollLeft({
186
+ playheadX,
187
+ currentScrollLeft: scroll.scrollLeft,
188
+ viewportWidth: scroll.clientWidth,
189
+ contentOrigin: contentOriginRef.current,
190
+ maxScrollLeft: scroll.scrollWidth - scroll.clientWidth,
191
+ })
192
+ : revealPlayheadScrollLeft(scroll, playheadX, contentOriginRef.current);
164
193
  if (Math.abs(nextScrollLeft - scroll.scrollLeft) >= 0.5) {
165
194
  scroll.scrollLeft = nextScrollLeft;
166
195
  }
@@ -1,6 +1,6 @@
1
1
  import { useRef, useMemo, useCallback, useState } from "react";
2
2
  import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
3
- import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
+ import { usePlayerStore } from "../store/playerStore";
4
4
  import { defaultTimelineTheme } from "./timelineTheme";
5
5
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
6
6
  import { useTimelineZoom } from "./useTimelineZoom";
@@ -8,18 +8,14 @@ import { useTimelineAssetDrop } from "./timelineDragDrop";
8
8
  import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
9
9
  import { useTimelineClipDrag } from "./useTimelineClipDrag";
10
10
  import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvider";
11
- import {
12
- buildTimelineMeta,
13
- resolveMultiDragPreview,
14
- resolveResizingElementIds,
15
- shouldIgnoreTimelinePointerDown,
16
- } from "./timelineProviderStateBuilders";
11
+ import { buildTimelineShellMeta } from "./timelineShellMeta";
12
+ import { useTimelineDragPreviewState } from "./useTimelineDragPreviewState";
17
13
  import { useTimelineOverlaysState } from "./useTimelineOverlaysState";
18
14
  import { useTimelineEditPinning } from "./useTimelineEditPinning";
19
15
  import { useTimelineStackingSync } from "./useTimelineStackingSync";
20
16
  import { useTimelineGeometry } from "./useTimelineGeometry";
21
17
  import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
22
- import { GUTTER, LABEL_COL_W } from "./timelineLayout";
18
+ import { GUTTER } from "./timelineLayout";
23
19
  import { useTimelineLabelColumn } from "./useTimelineLabelColumn";
24
20
  import { useTimelineKeyframeData } from "./useTimelineKeyframeData";
25
21
  import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
@@ -33,9 +29,9 @@ import {
33
29
  } from "./useTimelineTrackLayout";
34
30
  import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
35
31
  import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
36
- import { TimelineRazorGuideOverlay, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
32
+ import { useTimelineRazorInteraction } from "./TimelineRazorInteraction";
37
33
  import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
38
- import { getEffectiveTimelineDuration, getTimelinePreviewElement } from "./timelineViewModel";
34
+ import { getEffectiveTimelineDuration } from "./timelineViewModel";
39
35
  import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
40
36
  import { useTimelineTicks } from "./useTimelineTicks";
41
37
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
@@ -44,7 +40,6 @@ import { useTimelineActiveClips } from "./useTimelineActiveClips";
44
40
  import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
45
41
  import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
46
42
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
47
- import { resolveSnapGuide } from "./timelineSnapping";
48
43
  export function useTimelineProviderState({
49
44
  onSeek,
50
45
  onDrillDown,
@@ -107,8 +102,7 @@ export function useTimelineProviderState({
107
102
  const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
108
103
  const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
109
104
  const { gsapAnimations, keyframeCache } = useTimelineKeyframeData(showKeyframes);
110
- const namedAnimations = usePlayerStore((s) => s.gsapAnimations);
111
- const { labelMode, contentOrigin } = useTimelineLabelColumn(namedAnimations, timelineElements);
105
+ const { labelMode, contentOrigin } = useTimelineLabelColumn(gsapAnimations, timelineElements);
112
106
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
113
107
  const currentTime = usePlayerStore((s) => s.currentTime);
114
108
  const beatDragging = usePlayerStore((s) => s.beatDragging);
@@ -223,7 +217,8 @@ export function useTimelineProviderState({
223
217
  sessionEpoch,
224
218
  });
225
219
  const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
226
- const resizingElementIds = resolveResizingElementIds(resizingClip);
220
+ const { resizingElementIds, getPreviewElement, draggedElement, multiDragPreview, snapGuide } =
221
+ useTimelineDragPreviewState(draggedClip, resizingClip, selectedElementIds, timelineElements);
227
222
  const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
228
223
  totalClipCount: timelineElements.length,
229
224
  totalRowCount: displayLayout.displayTrackOrder.length,
@@ -422,12 +417,6 @@ export function useTimelineProviderState({
422
417
  timeDisplayMode,
423
418
  timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
424
419
  );
425
- const getPreviewElement = useCallback(
426
- (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
427
- [resizingClip],
428
- );
429
- const draggedElement = draggedClip?.element ?? null;
430
- const multiDragPreview = resolveMultiDragPreview(draggedClip, selectedElementIds);
431
420
  const canvasProps = {
432
421
  major,
433
422
  minor,
@@ -502,7 +491,7 @@ export function useTimelineProviderState({
502
491
  onMoveElement,
503
492
  beatDragging,
504
493
  draggedElement,
505
- snapGuide: resolveSnapGuide(draggedClip, resizingClip),
494
+ snapGuide,
506
495
  multiDragPreview,
507
496
  onToggleTrackHidden: editContext.onToggleTrackHidden,
508
497
  onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
@@ -520,60 +509,34 @@ export function useTimelineProviderState({
520
509
  )),
521
510
  [holdNewClipContent, renderClipContent],
522
511
  );
523
- const timelineMeta = buildTimelineMeta({
524
- emptyState: {
525
- isDragOver: assetDrop.isDragOver,
526
- onFileDrop: !!onFileDrop,
512
+ const timelineMeta = buildTimelineShellMeta({
513
+ isDragOver: assetDrop.isDragOver,
514
+ hasFileDrop: !!onFileDrop,
515
+ drop: {
527
516
  onDragOver: assetDrop.handleAssetDragOver,
528
517
  onDragLeave: assetDrop.handleAssetDragLeave,
529
518
  onDrop: assetDrop.handleAssetDrop,
530
519
  },
531
- container: {
532
- ref: setContainerRef,
533
- "aria-label": "Timeline track view",
534
- "data-timeline-element-count": timelineElements.length,
535
- isDragOver: assetDrop.isDragOver,
536
- activeTool,
537
- shiftHeld,
538
- accentClass: "ring-1 ring-inset ring-studio-accent/60",
539
- onMouseMove: updateRazorGuide,
540
- onMouseLeave: clearRazorGuide,
541
- style: {
542
- touchAction: "pan-x pan-y",
543
- background: theme.shellBackground,
544
- borderColor: theme.shellBorder,
545
- },
546
- },
547
- viewport: {
548
- ref: setScrollRef,
549
- tabIndex: -1,
550
- labelMode,
551
- zoomMode,
552
- onScroll: (e) => {
553
- lastScrollLeftRef.current = e.currentTarget.scrollLeft;
554
- recordTimelineScroll(e.currentTarget);
555
- syncScrollViewport(e.currentTarget, true);
556
- },
557
- ...timelineFocus.timelineFocusProps,
558
- onDragOver: assetDrop.handleAssetDragOver,
559
- onDragLeave: assetDrop.handleAssetDragLeave,
560
- onDrop: assetDrop.handleAssetDrop,
561
- onPointerDown: (e) => {
562
- if (shouldIgnoreTimelinePointerDown(e.target)) return;
563
- if (splitAllAtPointer(e)) return;
564
- handlePointerDown(e);
565
- },
520
+ pointer: {
521
+ onPointerDown: handlePointerDown,
566
522
  onPointerMove: handlePointerMove,
567
523
  onPointerUp: handlePointerUp,
568
524
  onPointerCancel: handlePointerCancel,
569
- onLostPointerCapture: handlePointerCancel,
570
525
  },
526
+ setContainerRef,
527
+ setScrollRef,
528
+ focusProps: timelineFocus.timelineFocusProps,
571
529
  elementCount: timelineElements.length,
572
- labelColumnWidth: LABEL_COL_W,
573
- razorGuide:
574
- activeTool === "razor" && razorGuideX !== null ? (
575
- <TimelineRazorGuideOverlay x={razorGuideX} />
576
- ) : null,
530
+ activeTool,
531
+ shiftHeld,
532
+ labelMode,
533
+ contentOrigin,
534
+ zoomMode,
535
+ theme,
536
+ razor: { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer },
537
+ lastScrollLeftRef,
538
+ recordTimelineScroll,
539
+ syncScrollViewport,
577
540
  });
578
541
  const contextValue: TimelineContextValue = {
579
542
  state: {
@@ -1,4 +1,5 @@
1
1
  // fallow-ignore-file dead-code
2
+ import { useCallback } from "react";
2
3
  import { usePlayerStore, type ZoomMode } from "../store/playerStore";
3
4
 
4
5
  export interface TimelineZoomState {
@@ -13,6 +14,13 @@ export function useTimelineZoom(): TimelineZoomState {
13
14
  const zoomMode = usePlayerStore((s) => s.zoomMode);
14
15
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
15
16
  const setZoomMode = usePlayerStore((s) => s.setZoomMode);
16
- const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
17
+ const setStorePercent = usePlayerStore((s) => s.setManualZoomPercent);
18
+ const setManualZoomPercent = useCallback(
19
+ (percent: number) => {
20
+ usePlayerStore.setState((s) => ({ userZoomCount: s.userZoomCount + 1 }));
21
+ setStorePercent(percent);
22
+ },
23
+ [setStorePercent],
24
+ );
17
25
  return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent };
18
26
  }
@@ -6,6 +6,8 @@ import type { useTimelinePlayer } from "./useTimelinePlayer";
6
6
  import {
7
7
  attachIframeAdapter,
8
8
  attachIframeWindow,
9
+ makeAdapterWindow,
10
+ makeFakeIframe,
9
11
  renderTimelinePlayerHarness,
10
12
  resetPlayerStore,
11
13
  } from "./timelinePlayerTestHarness";
@@ -263,6 +265,48 @@ describe("useTimelinePlayer audio controls (#835)", () => {
263
265
  });
264
266
 
265
267
  describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
268
+ it("publishes a seek as a person's seek, so a paused timeline follows it", () => {
269
+ const { root, api } = renderAttachedTimelinePlayer();
270
+ const before = liveTime.seekCount();
271
+ seekWithAct(api, 5);
272
+ expect(liveTime.seekCount()).toBe(before + 1);
273
+ expect(liveTime.latest()).toBe(5);
274
+ unmountWithAct(root);
275
+ });
276
+
277
+ it("does not count a reload's hand-over as a person's seek", () => {
278
+ const { getApi, root } = renderTimelinePlayerHarness();
279
+ act(() => {
280
+ getApi().iframeRef.current = makeFakeIframe(makeAdapterWindow().win);
281
+ getApi().onIframeLoad();
282
+ });
283
+ seekWithAct(getApi(), 7);
284
+ act(() => getApi().refreshPlayer());
285
+ const seeks = liveTime.seekCount();
286
+ const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
287
+ const shadow = makeFakeIframe(makeAdapterWindow().win);
288
+ shadow.src = "http://localhost/api/projects/demo/preview?_t=1";
289
+ act(() => {
290
+ getApi().setShadowIframeNode(shadow);
291
+ getApi().onShadowIframeLoad(gen);
292
+ getApi().onShadowReadyChange(gen, true);
293
+ });
294
+ expect(getApi().iframeRef.current).toBe(shadow);
295
+ expect(liveTime.latest()).toBe(7);
296
+ expect(liveTime.seekCount()).toBe(seeks);
297
+ unmountWithAct(root);
298
+ });
299
+
300
+ it("does not count the audition's return to the paused time as a person's seek", () => {
301
+ const { api, root } = renderAttachedTimelinePlayer();
302
+ seekWithAct(api, 4);
303
+ const seeks = liveTime.seekCount();
304
+ act(() => usePlayerStore.getState().requestPlayback(false, 2));
305
+ expect(liveTime.latest()).toBe(2);
306
+ expect(liveTime.seekCount()).toBe(seeks);
307
+ unmountWithAct(root);
308
+ });
309
+
266
310
  it("default seek() clears isPlaying when the store reports playing", () => {
267
311
  const { api, root } = renderAttachedTimelinePlayer();
268
312
  setStorePlaying();
@@ -47,6 +47,9 @@ export interface UseTimelinePlayerOptions {
47
47
  onPreviewReloadFailed?: (message: string) => void;
48
48
  }
49
49
 
50
+ const publishSeek = (time: number, options?: { follow?: boolean }) =>
51
+ options?.follow === false ? liveTime.notify(time) : liveTime.notifySeek(time);
52
+
50
53
  export function useTimelinePlayer({
51
54
  onShadowPromoted,
52
55
  onPreviewReloadFailed,
@@ -276,7 +279,7 @@ export function useTimelinePlayer({
276
279
  stopRAFLoop();
277
280
  }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
278
281
  const seek = useCallback(
279
- (time: number, options?: { keepPlaying?: boolean }) => {
282
+ (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => {
280
283
  const wasReverseShuttle = shuttleDirectionRef.current === "backward";
281
284
  stopReverseLoop();
282
285
  const adapter = getAdapter();
@@ -295,7 +298,7 @@ export function useTimelinePlayer({
295
298
  nextTime,
296
299
  });
297
300
  adapter.seek(nextTime, options);
298
- liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
301
+ publishSeek(nextTime, options); // Direct DOM updates (playhead, timecode, progress) — no re-render
299
302
  setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
300
303
  if (!shouldResumeAfterSeek && !keepPlaying) scrubMusicAtSeek(iframeRef.current, nextTime);
301
304
  if (shouldResumeAfterSeek) {
@@ -344,7 +347,7 @@ export function useTimelinePlayer({
344
347
  if (request.playing) play();
345
348
  else {
346
349
  pause();
347
- if (request.returnTo !== null) seek(request.returnTo);
350
+ if (request.returnTo !== null) seek(request.returnTo, { follow: false });
348
351
  }
349
352
  usePlayerStore.getState().clearPlaybackRequest();
350
353
  }
@@ -409,7 +412,7 @@ export function useTimelinePlayer({
409
412
  onReloadFailed: onPreviewReloadFailed,
410
413
  handOverPlayback: (time, playing) => {
411
414
  // keepPlaying: move the playhead without the paused-seek audio scrub.
412
- seek(time, { keepPlaying: true });
415
+ seek(time, { keepPlaying: true, follow: false });
413
416
  const adapter = getAdapter();
414
417
  // An edit that cut the film short of the live time stops it at the new end, as playback does.
415
418
  if (playing && adapter && adapter.getTime() < adapter.getDuration()) play();
@@ -7,6 +7,8 @@ import {
7
7
  mergeTimelineElementsPreservingDowngrades,
8
8
  } from "./timelineDOM";
9
9
  import { isTimelineIgnoredElement } from "./timelineElementHelpers";
10
+ import { clipTimingStart, resolveClipTimingBasis } from "../../hooks/gsapShared";
11
+ import { toAuthoredStart } from "../store/timelineElement";
10
12
  import { computeResizePreview } from "../components/timelineClipDragPreview";
11
13
  import type { TimelineElement } from "../store/playerStore";
12
14
  import {
@@ -25,6 +27,83 @@ function makeDoc(html: string): Document {
25
27
  return d;
26
28
  }
27
29
 
30
+ describe("parseTimelineFromDOM — nested master time", () => {
31
+ it("adds every enclosing host's start to a clip inside a sub-composition", () => {
32
+ const doc = makeDoc(`
33
+ <div data-composition-id="main" data-start="0" data-duration="20">
34
+ <div id="intro" data-composition-id="intro" data-start="2" data-duration="10">
35
+ <div data-composition-id="intro">
36
+ <div id="logo" data-composition-id="logo" data-start="3" data-duration="5">
37
+ <div data-composition-id="logo">
38
+ <div id="badge" class="clip" data-start="1" data-duration="2"></div>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ `);
45
+ const starts = parseTimelineFromDOM(doc, 20).map((e) => [
46
+ e.domId,
47
+ e.start,
48
+ e.parentCompositionStart,
49
+ ]);
50
+ expect(starts).toEqual([
51
+ ["intro", 2, 0],
52
+ ["logo", 5, 2],
53
+ ["badge", 6, 5],
54
+ ]);
55
+ });
56
+
57
+ it("places a clip inside a referenced scene after the scene's authored length", () => {
58
+ const doc = makeDoc(`
59
+ <div data-composition-id="main" data-start="0" data-duration="20">
60
+ <div id="s1" data-composition-id="s1" data-start="0" data-hf-authored-duration="8"></div>
61
+ <div id="s2" data-composition-id="s2" data-start="s1 + 1" data-duration="6">
62
+ <div data-composition-id="s2">
63
+ <div id="c" class="clip" data-start="1" data-duration="2"></div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ `);
68
+ const timelines = { s1: { duration: () => 6 } } as never;
69
+ const c = parseTimelineFromDOM(doc, 20, timelines).find((e) => e.domId === "c");
70
+ expect(c?.start).toBe(10);
71
+ });
72
+ });
73
+
74
+ describe("parseTimelineFromDOM — nested rows' keyframe basis", () => {
75
+ const doc = () =>
76
+ makeDoc(`
77
+ <div data-composition-id="main" data-start="0" data-duration="20">
78
+ <div id="intro" data-composition-id="intro" data-start="2" data-duration="10">
79
+ <div data-composition-id="intro">
80
+ <video id="vo" data-start="7" data-duration="2" data-hf-media-start-basis="global"></video>
81
+ <div id="logo" data-composition-id="logo" data-start="3" data-duration="5">
82
+ <div data-composition-id="logo">
83
+ <div id="badge" class="clip" data-start="1" data-duration="2"></div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ `);
90
+
91
+ it("measures diamonds and keyframe percentages against the local tween clock", () => {
92
+ const rows = parseTimelineFromDOM(doc(), 20);
93
+ const basis = (id: string) => resolveClipTimingBasis(id, "index.html", rows, []).elStart;
94
+ const at = (id: string) => rows.find((e) => e.domId === id)!;
95
+ expect([clipTimingStart(at("logo")), basis("logo")]).toEqual([3, 3]);
96
+ expect([clipTimingStart(at("badge")), basis("badge")]).toEqual([1, 1]);
97
+ });
98
+
99
+ it("keys a legacy root-time video on its host's clock but writes its start as master time", () => {
100
+ const vo = parseTimelineFromDOM(doc(), 20).find((e) => e.domId === "vo")!;
101
+ expect(vo.start).toBe(7);
102
+ expect(clipTimingStart(vo)).toBe(5);
103
+ expect(toAuthoredStart(vo, 8)).toBe(8);
104
+ });
105
+ });
106
+
28
107
  describe("parseTimelineFromDOM — media in-point", () => {
29
108
  it("reads a negative in-point as 0, as the runtime does, so a head trim keeps the clip", () => {
30
109
  const doc = makeDoc(`
@@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
12
12
  import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes";
13
13
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
14
14
  import { readClipTiming } from "@hyperframes/core/composition-contract";
15
+ import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
15
16
  import { groupInfoFor } from "./timelineGroupInfo";
16
17
  import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
17
18
  import {
@@ -228,10 +229,13 @@ export function parseTimelineFromDOM(
228
229
  const nodes = doc.querySelectorAll("[data-start]");
229
230
  const els: TimelineElement[] = [];
230
231
  let trackCounter = 0;
231
- const transitionLabels = transitionLabelsForDocument(
232
- doc,
233
- timelines ?? (doc.defaultView as IframeWindow | null)?.__timelines,
234
- );
232
+ const timelineRegistry = timelines ?? (doc.defaultView as IframeWindow | null)?.__timelines;
233
+ const transitionLabels = transitionLabelsForDocument(doc, timelineRegistry);
234
+ const masterStart = createRuntimeStartTimeResolver({
235
+ timelineRegistry,
236
+ includeAuthoredTimingAttrs: true,
237
+ documentRef: doc,
238
+ });
235
239
 
236
240
  // fallow-ignore-next-line complexity
237
241
  nodes.forEach((node) => {
@@ -239,11 +243,14 @@ export function parseTimelineFromDOM(
239
243
  if (isTimelineIgnoredElement(node)) return;
240
244
  const el = node as HTMLElement;
241
245
  const timing = readClipTiming(el);
242
- const start = timing.start;
243
- if (start == null) return;
246
+ if (timing.start == null) return;
247
+ const tagLower = el.tagName.toLowerCase();
248
+ const start =
249
+ tagLower === "video" || tagLower === "audio"
250
+ ? masterStart.resolveMediaStartForElement(el)
251
+ : masterStart.resolveStartForElement(el);
244
252
  if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
245
253
 
246
- const tagLower = el.tagName.toLowerCase();
247
254
  let dur = timing.duration ?? 0;
248
255
  if (dur <= 0) dur = Math.max(0, rootDuration - start);
249
256
  if (Number.isFinite(rootDuration) && rootDuration > 0) {
@@ -287,6 +294,8 @@ export function parseTimelineFromDOM(
287
294
  : "element",
288
295
  tag: tagLower,
289
296
  start,
297
+ parentCompositionStart: masterStart.resolveHostStartForElement(el),
298
+ ...(masterStart.isRootGlobalMediaStartForElement(el) && { authoredStartIsMasterTime: true }),
290
299
  duration: dur,
291
300
  track,
292
301
  domId: el.id || undefined,
@@ -103,6 +103,42 @@ describe("buildMissingCompositionElements — hfId (R7)", () => {
103
103
  });
104
104
  });
105
105
 
106
+ describe("buildMissingCompositionElements — nested master time", () => {
107
+ it("places hosts two and three levels deep at their master time", () => {
108
+ const doc = makeDoc(`
109
+ <div data-composition-id="main" data-start="0" data-duration="20">
110
+ <div id="intro" data-composition-id="intro" data-start="2" data-duration="10">
111
+ <div data-composition-id="intro">
112
+ <div id="logo" data-composition-id="logo" data-start="3" data-duration="5">
113
+ <div data-composition-id="logo">
114
+ <div id="badge" data-composition-id="badge" data-start="1" data-duration="2"></div>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ `);
121
+ const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 20);
122
+ expect(missing.map((e) => [e.domId, e.start, e.duration, e.parentCompositionStart])).toEqual([
123
+ ["intro", 2, 10, 0],
124
+ ["logo", 5, 5, 2],
125
+ ["badge", 6, 2, 5],
126
+ ]);
127
+ });
128
+
129
+ it("starts a host after a referenced scene's authored length", () => {
130
+ const doc = makeDoc(`
131
+ <div data-composition-id="main" data-start="0" data-duration="20">
132
+ <div id="s1" data-composition-id="s1" data-start="0" data-hf-authored-duration="8"></div>
133
+ <div id="s2" data-composition-id="s2" data-start="s1 + 1" data-duration="4"></div>
134
+ </div>
135
+ `);
136
+ const win = { __timelines: { s1: { duration: () => 6 } } } as unknown as IframeWindow;
137
+ const { missing } = buildMissingCompositionElements(doc, win, [], 20);
138
+ expect(missing.find((e) => e.domId === "s2")?.start).toBe(9);
139
+ });
140
+ });
141
+
106
142
  describe("setPreviewMediaVolume", () => {
107
143
  it("sends a clamped runtime volume to a direct preview iframe", () => {
108
144
  const iframe = document.createElement("iframe");