@hyperframes/studio 0.8.138 → 0.8.140

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 (135) hide show
  1. package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
  5. package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
  6. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  7. package/dist/chunk-TFLU2MGI.js.map +1 -0
  8. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  9. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  10. package/dist/index.d.ts +20 -3
  11. package/dist/index.html +1 -1
  12. package/dist/index.js +7327 -6265
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/package.json +8 -8
  16. package/src/App.tsx +9 -4
  17. package/src/components/TimelineToolbar.test.tsx +79 -1
  18. package/src/components/TimelineToolbar.tsx +14 -29
  19. package/src/components/editor/DomEditOverlay.tsx +11 -7
  20. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  21. package/src/components/editor/PropertyPanel.tsx +1 -0
  22. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  24. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  25. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  26. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  27. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  28. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  29. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  30. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  31. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  32. package/src/components/editor/manualEdits.ts +1 -1
  33. package/src/components/editor/manualEditsDom.ts +7 -7
  34. package/src/components/editor/manualEditsParsing.ts +14 -2
  35. package/src/components/editor/manualEditsTypes.ts +5 -0
  36. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  37. package/src/components/editor/manualOffsetDrag.ts +5 -0
  38. package/src/components/editor/previewGestureStarts.ts +284 -0
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  40. package/src/components/editor/propertyPanelFill.tsx +36 -13
  41. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  42. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  43. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  44. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  45. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  46. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  47. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  48. package/src/components/nle/NLEContext.test.ts +26 -0
  49. package/src/components/nle/NLEContext.tsx +8 -3
  50. package/src/components/nle/NLEPreview.test.ts +44 -1
  51. package/src/components/nle/NLEPreview.tsx +7 -2
  52. package/src/hooks/draggedGsapPosition.ts +8 -0
  53. package/src/hooks/editMoment.ts +13 -0
  54. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  55. package/src/hooks/gsapDragCommit.ts +2 -1
  56. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  57. package/src/hooks/gsapPositionDetection.ts +5 -3
  58. package/src/hooks/gsapResizeIntercept.ts +11 -4
  59. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  60. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  61. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  62. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  63. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  64. package/src/hooks/timelineEditingHelpers.ts +18 -2
  65. package/src/hooks/useDomSelection.test.ts +21 -2
  66. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  67. package/src/hooks/useEditHistoryActions.ts +2 -0
  68. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  69. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  70. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  71. package/src/hooks/useGsapAwareEditing.ts +148 -136
  72. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  73. package/src/hooks/useGsapScriptCommits.ts +5 -4
  74. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  75. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  76. package/src/hooks/usePreviewPersistence.ts +1 -4
  77. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  78. package/src/hooks/useSdkSession.ts +4 -1
  79. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  80. package/src/hooks/useThumbnailStripSize.ts +23 -1
  81. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  82. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  83. package/src/index.ts +2 -0
  84. package/src/player/components/AudioWaveform.test.tsx +153 -1
  85. package/src/player/components/AudioWaveform.tsx +93 -37
  86. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  87. package/src/player/components/Timeline.test.ts +3 -1
  88. package/src/player/components/TimelineCanvas.tsx +4 -0
  89. package/src/player/components/TimelineClip.tsx +20 -6
  90. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  91. package/src/player/components/TimelineClipFades.tsx +4 -4
  92. package/src/player/components/TimelineClipJoins.tsx +23 -18
  93. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  94. package/src/player/components/TimelineGroupRow.tsx +1 -0
  95. package/src/player/components/TimelineLaneClip.tsx +127 -0
  96. package/src/player/components/TimelineLanes.test.tsx +51 -6
  97. package/src/player/components/TimelineLanes.tsx +166 -178
  98. package/src/player/components/TimelineRuler.tsx +2 -0
  99. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  100. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  101. package/src/player/components/VideoThumbnail.tsx +37 -9
  102. package/src/player/components/thumbnailUtils.test.ts +17 -3
  103. package/src/player/components/thumbnailUtils.ts +17 -1
  104. package/src/player/components/timelineLayout.test.ts +3 -3
  105. package/src/player/components/timelineMotion.test.ts +27 -0
  106. package/src/player/components/timelineMotion.ts +62 -0
  107. package/src/player/components/timelineZoomInput.test.ts +438 -0
  108. package/src/player/components/timelineZoomInput.ts +456 -0
  109. package/src/player/components/useTimelineGeometry.ts +0 -3
  110. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  111. package/src/player/components/useTimelinePlayhead.ts +53 -59
  112. package/src/player/components/useTimelineProviderState.tsx +16 -21
  113. package/src/player/components/useTimelineZoom.ts +7 -12
  114. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  115. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  116. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  117. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  118. package/src/player/lib/thumbnailScheduler.ts +1 -1
  119. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  120. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  121. package/src/player/lib/timelineElementHelpers.ts +0 -11
  122. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  123. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  124. package/src/player/previewReloading.test.ts +48 -0
  125. package/src/player/previewReloading.ts +48 -0
  126. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  127. package/src/utils/rootDuration.ts +5 -4
  128. package/src/utils/studioPendingEdits.test.ts +27 -0
  129. package/src/utils/studioPendingEdits.ts +9 -0
  130. package/src/utils/timelineAssetDrop.test.ts +13 -4
  131. package/src/utils/timelineAssetDrop.ts +6 -2
  132. package/dist/chunk-TDWGXBUT.js.map +0 -1
  133. /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
  134. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  135. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -89,8 +89,11 @@ interface RenderLanesOptions {
89
89
  renderClipContent?: React.ComponentProps<typeof TimelineLanes>["renderClipContent"];
90
90
  snapGuide?: { time: number; type: "beat" | "clip-edge" | "playhead" } | null;
91
91
  withoutSelectHandler?: boolean;
92
+ pps?: number;
92
93
  }
93
94
 
95
+ const lanePps = (options: RenderLanesOptions) => options.pps ?? 100;
96
+
94
97
  const selectHandlerFor = (
95
98
  options: RenderLanesOptions,
96
99
  handler: (element: TimelineElement | null) => void,
@@ -102,12 +105,14 @@ function renderLanes(options: RenderLanesOptions = {}): {
102
105
  rerender: (next: RenderLanesOptions) => void;
103
106
  setSelectedElementId: ReturnType<typeof vi.fn>;
104
107
  onSelectElement: ReturnType<typeof vi.fn>;
108
+ onRazorSplit: ReturnType<typeof vi.fn>;
105
109
  } {
106
110
  const host = document.createElement("div");
107
111
  document.body.append(host);
108
112
  const root = createRoot(host);
109
113
  const setSelectedElementId = vi.fn();
110
114
  const onSelectElement = vi.fn();
115
+ const onRazorSplit = vi.fn();
111
116
  const render = (next: RenderLanesOptions) => {
112
117
  const elements = next.elements ?? [element("clip-a", TRACK_A)];
113
118
  const gsapAnimations = next.animations ?? new Map<string, GsapAnimation[]>();
@@ -124,7 +129,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
124
129
  usePlayerStore.setState({ expandedClipIds: new Set(next.expandedClipIds ?? []) });
125
130
  root.render(
126
131
  <TimelineLanes
127
- pps={100}
132
+ pps={lanePps(next)}
128
133
  contentOrigin={232}
129
134
  contentGutter={32}
130
135
  trackContentWidth={800}
@@ -186,14 +191,14 @@ function renderLanes(options: RenderLanesOptions = {}): {
186
191
  onResizeElement={vi.fn()}
187
192
  onMoveElement={vi.fn()}
188
193
  onSelectElement={selectHandlerFor(next, onSelectElement)}
189
- onRazorSplit={vi.fn()}
194
+ onRazorSplit={onRazorSplit}
190
195
  onRazorSplitAll={vi.fn()}
191
196
  />,
192
197
  );
193
198
  });
194
199
  };
195
200
  render(options);
196
- return { host, root, rerender: render, setSelectedElementId, onSelectElement };
201
+ return { host, root, rerender: render, setSelectedElementId, onSelectElement, onRazorSplit };
197
202
  }
198
203
 
199
204
  function visibilityLabels(host: HTMLElement): (string | null)[] {
@@ -586,12 +591,18 @@ describe("TimelineLanes clip joins", () => {
586
591
 
587
592
  const joins = view.host.querySelectorAll<HTMLElement>("[data-timeline-clip-join]");
588
593
  expect(joins).toHaveLength(1);
589
- expect(joins[0]?.style.left).toBe("200px");
594
+ // Both sit in one-second layers 100px wide: 200% is 200px, at the 2 s join.
595
+ const layerWidth = (el: Element | null | undefined) =>
596
+ (el?.closest("[data-timeline-time-layer]") as HTMLElement | null)?.style.width;
597
+ expect(joins[0]?.style.left).toBe("200%");
598
+ expect(layerWidth(joins[0])).toBe("100px");
590
599
  expect(joins[0]?.style.width).toBe("1px");
591
600
  expect(joins[0]?.style.background).toBe(defaultTimelineTheme.rowBackground);
592
601
  const clipB = view.host.querySelector<HTMLElement>('[data-el-id="clip-b"]');
593
- expect(clipB?.style.left).toBe("200px");
594
- expect(clipB?.style.width).toBe("150px");
602
+ expect(clipB?.style.left).toBe("200%");
603
+ expect(clipB?.style.width).toBe("150%");
604
+ expect(clipB?.style.minWidth).toBe("4px");
605
+ expect(layerWidth(clipB)).toBe("100px");
595
606
  act(() => view.root.unmount());
596
607
  });
597
608
 
@@ -624,3 +635,37 @@ describe("TimelineLanes transition seams", () => {
624
635
  act(() => view.root.unmount());
625
636
  });
626
637
  });
638
+
639
+ describe("TimelineLanes clips during a zoom", () => {
640
+ afterEach(() => {
641
+ usePlayerStore.setState({ activeTool: "select" });
642
+ });
643
+
644
+ it("leaves a clip unrendered by a zoom step that does not change what it draws", () => {
645
+ const renderClipContent = vi.fn(() => null);
646
+ const clip = { ...element("clip-a", TRACK_A), start: 0, duration: 2 };
647
+ const view = renderLanes({ elements: [clip], renderClipContent, pps: 100 });
648
+ const drawn = renderClipContent.mock.calls.length;
649
+ // 200px to 220px: still a labelled clip with handles.
650
+ view.rerender({ elements: [clip], renderClipContent, pps: 110 });
651
+ expect(renderClipContent.mock.calls.length).toBe(drawn);
652
+ // 40px: the clip drops its label, so it draws again.
653
+ view.rerender({ elements: [clip], renderClipContent, pps: 20 });
654
+ expect(renderClipContent.mock.calls.length).toBeGreaterThan(drawn);
655
+ act(() => view.root.unmount());
656
+ });
657
+
658
+ it("splits with the razor at the time under the pointer at the current zoom", () => {
659
+ usePlayerStore.setState({ activeTool: "razor" });
660
+ const clip = { ...element("clip-a", TRACK_A), start: 0, duration: 2 };
661
+ const view = renderLanes({ elements: [clip], pps: 100 });
662
+ view.rerender({ elements: [clip], pps: 105 });
663
+ act(() => {
664
+ view.host
665
+ .querySelector('[data-el-id="clip-a"]')
666
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: 105 }));
667
+ });
668
+ expect(view.onRazorSplit).toHaveBeenCalledWith(expect.objectContaining({ id: "clip-a" }), 1);
669
+ act(() => view.root.unmount());
670
+ });
671
+ });
@@ -1,6 +1,6 @@
1
- import { Fragment, useId, useMemo, type CSSProperties } from "react";
1
+ import { Fragment, useId, useMemo, useRef, type CSSProperties } from "react";
2
2
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
3
- import { TimelineClip } from "./TimelineClip";
3
+ import { TimelineLaneClip, type TimelineLaneClipActions } from "./TimelineLaneClip";
4
4
  import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
5
5
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
6
6
  import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
@@ -10,6 +10,8 @@ import { TimelineTrackHeader } from "./TimelineTrackHeader";
10
10
  import { TimelineGroupRow } from "./TimelineGroupRow";
11
11
  import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes";
12
12
  import { useTimelineClipDisclosure } from "./useTimelineClipDisclosure";
13
+ import { TimelineTimeLayer } from "./TimelineTimeLayer";
14
+ import { clipWidthPx } from "./TimelineClip";
13
15
  import {
14
16
  isTrackRowExpanded,
15
17
  resolveTrackKeyframeClip,
@@ -24,8 +26,7 @@ import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMult
24
26
  import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows";
25
27
  import type { TimelineLanesProps } from "./timelineLaneProps";
26
28
  import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
27
- import { createClipGestureHandlers } from "./timelineClipGestureHandlers";
28
- import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
29
+ import { resolveClipRenderContext } from "./timelineClipChildren";
29
30
  import { TimelineTrackRow } from "./TimelineTrackRow";
30
31
  import { isTimelineClipActive } from "./useTimelineActiveClips";
31
32
  import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
@@ -143,6 +144,28 @@ export function TimelineLanes({
143
144
  onToggleRow: (row) => row.elementId && toggleClipExpandedTracked(row.elementId),
144
145
  onDrillDown,
145
146
  });
147
+ const clipActions = useRef<TimelineLaneClipActions>(null!);
148
+ clipActions.current = {
149
+ gestures: {
150
+ pps,
151
+ onResizeElement,
152
+ onMoveElement,
153
+ onRazorSplit,
154
+ onRazorSplitAll,
155
+ blockedClipRef,
156
+ suppressClickRef,
157
+ scrollRef,
158
+ setShowPopover,
159
+ setRangeSelection,
160
+ setResizingClip,
161
+ setDraggedClip,
162
+ setSelectedElementId,
163
+ onSelectElement,
164
+ },
165
+ setHoveredClip,
166
+ onContextMenuClip,
167
+ onDrillDown,
168
+ };
146
169
  return (
147
170
  <div
148
171
  role="treegrid"
@@ -330,6 +353,7 @@ export function TimelineLanes({
330
353
  <div
331
354
  role="gridcell"
332
355
  aria-colindex={2}
356
+ data-timeline-zoom-scale=""
333
357
  style={{
334
358
  width: trackContentWidth,
335
359
  marginLeft: contentGutter, // room for a 0% diamond left of t=0
@@ -368,181 +392,145 @@ export function TimelineLanes({
368
392
  renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
369
393
  />
370
394
  )}
371
- {
372
- // fallow-ignore-next-line complexity
373
- renderElements.map((el) => {
374
- const clipStyle = getTrackStyle(el.tag);
375
- const elementKey = getTimelineElementIdentity(el);
376
- // Only the track's active keyframe clip shows expanded lanes;
377
- // other clips (incl. siblings on a shared track) show compact
378
- // diamonds on their own bar instead.
379
- const isTrackKeyframeClip = elementKey === keyframeClipKey;
380
- const showsLanes = isTrackKeyframeClip && rowExpanded;
381
- const capabilities = getClipCapabilities(el);
382
- const isSelected =
383
- selectedElementId === elementKey || selectedElementIds.has(elementKey);
384
- const isComposition = !!el.compositionSrc;
385
- // Element identity stays stable across clip splices and reorders.
386
- const clipKey = elementKey;
387
- const isDraggingClip =
388
- draggedClip?.started === true &&
389
- draggedElement != null &&
390
- getTimelineElementIdentity(draggedElement) === elementKey;
391
- if (isDraggingClip) return null;
392
- const previewElement = getPreviewElement(el);
393
- const renderContext = resolveClipRenderContext(
394
- previewElement,
395
- visibleTimeRange,
396
- isSelected || hoveredClip === clipKey || pinnedClipIdentities.has(clipKey),
397
- );
398
- // Passenger of a live multi-drag: preserve the formation without changing
399
- // the passenger's timeline data until the owning drag commits.
400
- const isPassenger =
401
- multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
402
- const passengerStyle = isPassenger
403
- ? passengerStyleAt(multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview))
404
- : undefined;
405
- const clipGestures = createClipGestureHandlers(
406
- el,
407
- elementKey,
408
- previewElement,
409
- capabilities,
410
- {
411
- pps,
412
- onResizeElement,
413
- onMoveElement,
414
- onRazorSplit,
415
- onRazorSplitAll,
416
- blockedClipRef,
417
- suppressClickRef,
418
- scrollRef,
419
- setShowPopover,
420
- setRangeSelection,
421
- setResizingClip,
422
- setDraggedClip,
423
- setSelectedElementId,
424
- onSelectElement,
425
- },
426
- );
427
- const clip = (
428
- <TimelineClip
429
- key={clipKey}
430
- onContextMenu={(e: React.MouseEvent) => {
431
- e.preventDefault();
432
- onContextMenuClip?.(e, el);
433
- }}
434
- el={previewElement}
435
- pps={pps}
436
- passengerStyle={passengerStyle}
437
- clipY={CLIP_Y}
438
- clipHeight={clipBarHeight}
439
- isSelected={isSelected}
440
- isHovered={hoveredClip === clipKey}
441
- isDragging={false}
442
- isActive={isTimelineClipActive(previewElement, currentTime)}
443
- hasCustomContent={!!renderClipContent}
444
- capabilities={capabilities}
445
- theme={theme}
446
- isComposition={isComposition}
447
- tabIndex={
448
- keyboard.rovingTargetId === timelineClipFocusId(elementKey) ? 0 : -1
449
- }
450
- onHoverStart={() => setHoveredClip(clipKey)}
451
- onHoverEnd={() => setHoveredClip(null)}
452
- onResizeStart={clipGestures.onResizeStart}
453
- onPointerDown={clipGestures.onPointerDown}
454
- onClick={clipGestures.onClick}
455
- onDoubleClick={(e) => {
456
- e.stopPropagation();
457
- if (suppressClickRef.current) return;
458
- if (isComposition && onDrillDown) onDrillDown(el);
459
- }}
460
- >
461
- {renderClipChildren(
462
- previewElement,
463
- clipStyle,
464
- renderClipContent,
465
- renderClipOverlay,
466
- renderContext,
467
- )}
468
- </TimelineClip>
469
- );
470
- const compactKeyframes = keyframeCache?.get(elementKey);
471
- const compactDiamonds = !showsLanes && compactKeyframes && (
472
- <TimelineCompactDiamonds
473
- key={`${clipKey}-diamonds`}
474
- element={previewElement}
475
- elementId={elementKey}
476
- keyframesData={compactKeyframes}
477
- pixelsPerSecond={pps}
478
- rowHeight={rowHeight}
479
- beatsActive={beatStripOnTrack}
480
- accentColor={clipStyle.accent}
481
- isSelected={isSelected}
482
- passengerStyle={passengerStyle}
483
- currentTime={currentTime}
484
- selectedKeyframes={selectedKeyframes}
485
- rovingTargetId={keyboard.rovingTargetId}
486
- onClickKeyframe={onClickKeyframe}
487
- onShiftClickKeyframe={onShiftClickKeyframe}
488
- onContextMenuKeyframe={onContextMenuKeyframe}
489
- onMoveKeyframe={onMoveKeyframe}
490
- onSelectSegment={onSelectSegment}
491
- suppressClickRef={suppressClickRef}
492
- />
493
- );
494
- // Keep this shell mounted while collapsed so aria-controls stays valid
495
- // and multi-drag cannot remount the subtree mid-gesture.
496
- const propertyLanes = isTrackKeyframeClip && (
497
- <TimelinePropertyLanes
498
- key={`${clipKey}-property-lanes`}
499
- id={lanesId}
500
- animations={showsLanes ? (gsapAnimations.get(elementKey) ?? []) : []}
501
- // clipTimingStart, not the raw start: an expanded sub-comp
502
- // child's start is host-absolute while its tweens are
503
- // local to its own file.
504
- clipStart={clipTimingStart(previewElement)}
505
- clipDuration={previewElement.duration}
506
- clipLeftPx={previewElement.start * pps}
507
- clipWidthPx={Math.max(previewElement.duration * pps, 4)}
508
- passengerStyle={passengerStyle}
509
- accentColor={clipStyle.accent}
510
- isSelected={isSelected}
511
- currentPercentage={
512
- previewElement.duration > 0
513
- ? ((currentTime - previewElement.start) / previewElement.duration) * 100
514
- : 0
515
- }
516
- elementId={elementKey}
517
- selectedKeyframes={selectedKeyframes}
518
- rovingTargetId={keyboard.rovingTargetId}
519
- onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
520
- onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
521
- onShiftClickKeyframe={(target) =>
522
- onShiftClickKeyframe?.(elementKey, target)
523
- }
524
- onContextMenuKeyframe={(e, target) =>
525
- onContextMenuKeyframe?.(e, elementKey, target)
526
- }
527
- onMoveKeyframe={(target, toClipPercentage) =>
528
- onMoveKeyframe?.(elementKey, target, toClipPercentage) ??
529
- Promise.resolve(false)
530
- }
531
- suppressClickRef={suppressClickRef}
532
- />
533
- );
395
+ <TimelineTimeLayer pixelsPerSecond={pps}>
396
+ {
397
+ // fallow-ignore-next-line complexity
398
+ renderElements.map((el) => {
399
+ const clipStyle = getTrackStyle(el.tag);
400
+ const elementKey = getTimelineElementIdentity(el);
401
+ // Only the track's active keyframe clip shows expanded lanes;
402
+ // other clips (incl. siblings on a shared track) show compact
403
+ // diamonds on their own bar instead.
404
+ const isTrackKeyframeClip = elementKey === keyframeClipKey;
405
+ const showsLanes = isTrackKeyframeClip && rowExpanded;
406
+ const capabilities = getClipCapabilities(el);
407
+ const isSelected =
408
+ selectedElementId === elementKey || selectedElementIds.has(elementKey);
409
+ // Element identity stays stable across clip splices and reorders.
410
+ const clipKey = elementKey;
411
+ const isDraggingClip =
412
+ draggedClip?.started === true &&
413
+ draggedElement != null &&
414
+ getTimelineElementIdentity(draggedElement) === elementKey;
415
+ if (isDraggingClip) return null;
416
+ const previewElement = getPreviewElement(el);
417
+ const renderContext = resolveClipRenderContext(
418
+ previewElement,
419
+ visibleTimeRange,
420
+ isSelected || hoveredClip === clipKey || pinnedClipIdentities.has(clipKey),
421
+ );
422
+ // Passenger of a live multi-drag: preserve the formation without changing
423
+ // the passenger's timeline data until the owning drag commits.
424
+ const isPassenger =
425
+ multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
426
+ const passengerStyle = isPassenger
427
+ ? passengerStyleAt(
428
+ multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview),
429
+ )
430
+ : undefined;
431
+ const clip = (
432
+ <TimelineLaneClip
433
+ key={clipKey}
434
+ el={el}
435
+ previewElement={previewElement}
436
+ elementKey={elementKey}
437
+ capabilities={capabilities}
438
+ pps={pps}
439
+ passengerStyle={passengerStyle}
440
+ clipBarHeight={clipBarHeight}
441
+ isSelected={isSelected}
442
+ isHovered={hoveredClip === clipKey}
443
+ isActive={isTimelineClipActive(previewElement, currentTime)}
444
+ tabIndex={
445
+ keyboard.rovingTargetId === timelineClipFocusId(elementKey) ? 0 : -1
446
+ }
447
+ priority={renderContext.priority}
448
+ rich={renderContext.rich}
449
+ theme={theme}
450
+ clipStyle={clipStyle}
451
+ renderClipContent={renderClipContent}
452
+ renderClipOverlay={renderClipOverlay}
453
+ actions={clipActions}
454
+ />
455
+ );
456
+ const compactKeyframes = keyframeCache?.get(elementKey);
457
+ const compactDiamonds = !showsLanes && compactKeyframes && (
458
+ <TimelineCompactDiamonds
459
+ key={`${clipKey}-diamonds`}
460
+ element={previewElement}
461
+ elementId={elementKey}
462
+ keyframesData={compactKeyframes}
463
+ pixelsPerSecond={pps}
464
+ rowHeight={rowHeight}
465
+ beatsActive={beatStripOnTrack}
466
+ accentColor={clipStyle.accent}
467
+ isSelected={isSelected}
468
+ passengerStyle={passengerStyle}
469
+ currentTime={currentTime}
470
+ selectedKeyframes={selectedKeyframes}
471
+ rovingTargetId={keyboard.rovingTargetId}
472
+ onClickKeyframe={onClickKeyframe}
473
+ onShiftClickKeyframe={onShiftClickKeyframe}
474
+ onContextMenuKeyframe={onContextMenuKeyframe}
475
+ onMoveKeyframe={onMoveKeyframe}
476
+ onSelectSegment={onSelectSegment}
477
+ suppressClickRef={suppressClickRef}
478
+ />
479
+ );
480
+ // Keep this shell mounted while collapsed so aria-controls stays valid
481
+ // and multi-drag cannot remount the subtree mid-gesture.
482
+ const propertyLanes = isTrackKeyframeClip && (
483
+ <TimelinePropertyLanes
484
+ key={`${clipKey}-property-lanes`}
485
+ id={lanesId}
486
+ animations={showsLanes ? (gsapAnimations.get(elementKey) ?? []) : []}
487
+ // clipTimingStart, not the raw start: an expanded sub-comp
488
+ // child's start is host-absolute while its tweens are
489
+ // local to its own file.
490
+ clipStart={clipTimingStart(previewElement)}
491
+ clipDuration={previewElement.duration}
492
+ clipLeftPx={previewElement.start * pps}
493
+ clipWidthPx={clipWidthPx(previewElement, pps)}
494
+ passengerStyle={passengerStyle}
495
+ accentColor={clipStyle.accent}
496
+ isSelected={isSelected}
497
+ currentPercentage={
498
+ previewElement.duration > 0
499
+ ? ((currentTime - previewElement.start) / previewElement.duration) *
500
+ 100
501
+ : 0
502
+ }
503
+ elementId={elementKey}
504
+ selectedKeyframes={selectedKeyframes}
505
+ rovingTargetId={keyboard.rovingTargetId}
506
+ onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
507
+ onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
508
+ onShiftClickKeyframe={(target) =>
509
+ onShiftClickKeyframe?.(elementKey, target)
510
+ }
511
+ onContextMenuKeyframe={(e, target) =>
512
+ onContextMenuKeyframe?.(e, elementKey, target)
513
+ }
514
+ onMoveKeyframe={(target, toClipPercentage) =>
515
+ onMoveKeyframe?.(elementKey, target, toClipPercentage) ??
516
+ Promise.resolve(false)
517
+ }
518
+ suppressClickRef={suppressClickRef}
519
+ />
520
+ );
534
521
 
535
- // No wrapper node per clip, and the same Fragment whether or not it rides a
536
- // drag, so joining or leaving one restyles it, never remounts it.
537
- return (
538
- <Fragment key={clipKey}>
539
- {clip}
540
- {compactDiamonds}
541
- {propertyLanes}
542
- </Fragment>
543
- );
544
- })
545
- }
522
+ // No wrapper node per clip, and the same Fragment whether or not it rides a
523
+ // drag, so joining or leaving one restyles it, never remounts it.
524
+ return (
525
+ <Fragment key={clipKey}>
526
+ {clip}
527
+ {compactDiamonds}
528
+ {propertyLanes}
529
+ </Fragment>
530
+ );
531
+ })
532
+ }
533
+ </TimelineTimeLayer>
546
534
  <TimelineTransitionOverlays
547
535
  seams={transitionSeamsByTrack.get(trackNum) ?? []}
548
536
  rowElements={draggedClip?.started ? [] : automationElements}
@@ -107,6 +107,7 @@ export const TimelineRuler = memo(function TimelineRuler({
107
107
  {/* Background SVG — beat lines only; major-tick gridlines removed so only
108
108
  the ruler's own small ticks mark intervals (no full-height lines). */}
109
109
  <svg
110
+ data-timeline-zoom-scale=""
110
111
  className="absolute pointer-events-none"
111
112
  style={{ left: contentOrigin, width: trackContentWidth, zIndex: 0 }}
112
113
  height={totalH}
@@ -152,6 +153,7 @@ export const TimelineRuler = memo(function TimelineRuler({
152
153
  {/* Breathing pad before 00:00 is folded into contentOrigin (see
153
154
  Timeline.tsx: GUTTER + TRACKS_LEFT_PAD), so no separate pad div. */}
154
155
  <div
156
+ data-timeline-zoom-scale=""
155
157
  className="relative overflow-hidden"
156
158
  style={{
157
159
  height: RULER_H,
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * A box one second wide at the current zoom. What sits in it places itself in percent of it
5
+ * (`left: start * 100%`), so a zoom step rewrites this one width and no clip's style changes.
6
+ */
7
+ export function TimelineTimeLayer({
8
+ pixelsPerSecond,
9
+ children,
10
+ }: {
11
+ pixelsPerSecond: number;
12
+ children: ReactNode;
13
+ }) {
14
+ return (
15
+ <div
16
+ data-timeline-time-layer=""
17
+ className="absolute inset-y-0 left-0"
18
+ style={{ width: pixelsPerSecond }}
19
+ >
20
+ {children}
21
+ </div>
22
+ );
23
+ }
24
+
25
+ export const timeLayerPercent = (seconds: number) => `${seconds * 100}%`;