@hyperframes/studio 0.8.91 → 0.8.93

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 (37) hide show
  1. package/dist/assets/{hyperframes-player-zT2Bdx_l.js → hyperframes-player-CkV9KK6A.js} +3 -3
  2. package/dist/assets/{index-DV-z19zB.js → index-BlLFipOk.js} +1 -1
  3. package/dist/assets/{index-V-0rfhe6.js → index-CYk0NwSv.js} +165 -165
  4. package/dist/assets/{index-DSOLcE8X.js → index-V3fzEfOD.js} +1 -1
  5. package/dist/index.d.ts +6 -11
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +8792 -8803
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  11. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  12. package/src/player/components/Timeline.rulerSeek.test.tsx +7 -7
  13. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  14. package/src/player/components/timelineClipDragCommit.test.ts +14 -58
  15. package/src/player/components/timelineClipDragCommit.ts +14 -37
  16. package/src/player/components/timelineClipDragPreview.test.ts +213 -22
  17. package/src/player/components/timelineClipDragPreview.ts +46 -81
  18. package/src/player/components/timelineClipDragTypes.ts +3 -8
  19. package/src/player/components/timelineCollision.test.ts +100 -302
  20. package/src/player/components/timelineCollision.ts +73 -160
  21. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  22. package/src/player/components/timelineDragDrop.ts +7 -9
  23. package/src/player/components/timelineDragLanding.ts +0 -49
  24. package/src/player/components/timelineLayout.test.ts +0 -25
  25. package/src/player/components/timelineLayout.ts +0 -16
  26. package/src/player/components/timelineMultiDragPreview.test.ts +34 -25
  27. package/src/player/components/timelineMultiDragPreview.ts +25 -27
  28. package/src/player/components/timelineProviderStateBuilders.ts +7 -4
  29. package/src/player/components/timelineShellMeta.tsx +99 -0
  30. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  31. package/src/player/components/useTimelineProviderState.tsx +29 -67
  32. package/src/player/hooks/useTimelinePlayer.seek.test.ts +29 -1
  33. package/src/player/hooks/useTimelinePlayer.ts +6 -0
  34. package/src/player/hooks/useTimelinePlayerLoop.ts +6 -2
  35. package/src/player/lib/playbackTypes.ts +1 -0
  36. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  37. package/src/utils/timelineDropTrackInsert.ts +36 -5
@@ -1,3 +1,7 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import { clampToHostStart } from "../store/timelineElement";
3
+ import { canMoveTimelineElement } from "./timelineAuthoredMoveTarget";
4
+
1
5
  /**
2
6
  * Pure geometry for the LIVE multi-selection drag preview.
3
7
  *
@@ -9,11 +13,11 @@
9
13
  * behind individually — the whole formation moves by one delta, spacing locked.
10
14
  *
11
15
  * That single delta is the GRABBED clip's `draggedPreviewStart − draggedOriginStart`.
12
- * The preview start is ALREADY group-clamped upstream (updateDraggedClipPreview
13
- * runs clampGroupMoveDelta before setting it), so this delta is the clamped delta:
16
+ * The preview start is ALREADY group-clamped upstream (computeDragPreview floors it
17
+ * at groupMoveFloor), so this delta is the clamped delta:
14
18
  * the instant any member would cross 0 the grabbed clip stops and every passenger
15
19
  * stops with it — the formation never deforms. On DROP the commit shifts every
16
- * selected clip by this same delta (see timelineClipDragCommit / useTimelineClipDrag).
20
+ * moving clip (resolveGroupMovers) by this same delta (see timelineClipDragCommit).
17
21
  *
18
22
  * Track changes apply to the grabbed clip only (mirroring the commit); passengers
19
23
  * keep their lanes, so only their x moves.
@@ -81,29 +85,23 @@ export function multiDragPassengerOffsetPx(
81
85
  return multiDragDeltaSeconds(input) * pps;
82
86
  }
83
87
 
84
- /**
85
- * Clamp a group move so the WHOLE selection moves as ONE rigid formation.
86
- *
87
- * The grabbed clip proposes a raw delta (its desired preview start minus its
88
- * origin start, after its own snapping). Applied naively, a passenger could be
89
- * pushed below 0 (or past any other member bound), and the commit's per-clip
90
- * `Math.max(0, …)` would then deform the formation — the grabbed clip out-runs
91
- * the group while a passenger sticks at the wall. This ports main's model
92
- * (useTimelineClipGroupDrag / clampTimelineGroupMoveDelta): the applied delta is
93
- * bounded by the MOST-CONSTRAINED member, so the grabbed clip STOPS the instant
94
- * any member hits 0 and the formation never deforms.
95
- *
96
- * `memberStarts` are the pre-drag starts of every selected clip (the grabbed clip
97
- * included). Only the lower bound (start ≥ 0) constrains a move; the timeline has
98
- * no fixed right wall (the composition grows on commit).
99
- */
100
- export function clampGroupMoveDelta(
101
- rawDelta: number,
102
- members: readonly { start: number; minStart?: number }[],
88
+ /** The selected clips a group drag moves: a locked clip swept into the selection stays put.
89
+ * Null when the grabbed clip is not part of a multi-selection. */
90
+ export function resolveGroupMovers(
91
+ elements: readonly TimelineElement[],
92
+ selectedKeys: ReadonlySet<string> | null | undefined,
93
+ dragKey: string,
94
+ ): TimelineElement[] | null {
95
+ if (!selectedKeys || selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return null;
96
+ return elements.filter((e) => selectedKeys.has(e.key ?? e.id) && canMoveTimelineElement(e));
97
+ }
98
+
99
+ /** The lowest start the grabbed clip may take so the whole group moves rigidly and no clip
100
+ * crosses its own floor, its host composition's start (the commit floors each one there). */
101
+ export function groupMoveFloor(
102
+ grabbed: TimelineElement,
103
+ movers: readonly TimelineElement[],
103
104
  ): number {
104
- if (members.length === 0) return rawDelta;
105
- // The member nearest its own floor bounds the move: delta ≥ -(start - minStart) for each.
106
- const room = Math.min(...members.map((m) => m.start - (m.minStart ?? 0)));
107
- const minDelta = room === 0 ? 0 : -room; // avoid -0
108
- return rawDelta < minDelta ? minDelta : rawDelta;
105
+ const room = Math.min(...[grabbed, ...movers].map((e) => e.start - clampToHostStart(e, 0)));
106
+ return grabbed.start - room;
109
107
  }
@@ -5,23 +5,26 @@ import type {
5
5
  } from "./TimelineProvider";
6
6
  import type { ResizingClipState } from "./useTimelineClipDrag";
7
7
  import type { DraggedClipState } from "./timelineClipDragTypes";
8
- import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
8
+ import { resolveGroupMovers, type MultiDragPreviewInput } from "./timelineMultiDragPreview";
9
9
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
10
+ import type { TimelineElement } from "../store/playerStore";
10
11
 
11
12
  export function resolveMultiDragPreview(
12
13
  draggedClip: DraggedClipState | null,
13
14
  selectedKeys: ReadonlySet<string>,
15
+ elements: readonly TimelineElement[],
14
16
  ): MultiDragPreviewInput | null {
15
- // The dragged clip is a free ghost; selected companions follow the same
16
- // clamped delta so the formation stays rigid at the lane boundary.
17
+ // The dragged clip is a free ghost; the clips that move with it follow the same
18
+ // clamped delta so the formation stays rigid. A locked selected clip stays put.
17
19
  if (!draggedClip?.started) return null;
18
20
  const draggedKey = getTimelineElementIdentity(draggedClip.element);
21
+ const movers = resolveGroupMovers(elements, selectedKeys, draggedKey);
19
22
  return {
20
23
  dragStarted: true,
21
24
  draggedKey,
22
25
  draggedOriginStart: draggedClip.element.start,
23
26
  draggedPreviewStart: draggedClip.previewStart,
24
- selectedKeys,
27
+ selectedKeys: movers ? new Set(movers.map(getTimelineElementIdentity)) : selectedKeys,
25
28
  };
26
29
  }
27
30
 
@@ -0,0 +1,99 @@
1
+ import type { MutableRefObject, PointerEvent } from "react";
2
+ import type {
3
+ TimelineContainerProps,
4
+ TimelineMeta,
5
+ TimelineViewportProps,
6
+ } from "./TimelineProvider";
7
+ import {
8
+ buildTimelineMeta,
9
+ shouldIgnoreTimelinePointerDown,
10
+ } from "./timelineProviderStateBuilders";
11
+ import { TimelineRazorGuideOverlay } from "./TimelineRazorInteraction";
12
+ import { LABEL_COL_W } from "./timelineLayout";
13
+ import type { TimelineTheme } from "./timelineTheme";
14
+
15
+ type ViewportHandlers = Pick<
16
+ TimelineViewportProps,
17
+ "onDragOver" | "onDragLeave" | "onDrop" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
18
+ >;
19
+
20
+ export interface TimelineShellMetaInputs {
21
+ isDragOver: boolean;
22
+ hasFileDrop: boolean;
23
+ drop: Pick<ViewportHandlers, "onDragOver" | "onDragLeave" | "onDrop">;
24
+ pointer: Pick<ViewportHandlers, "onPointerMove" | "onPointerUp" | "onPointerCancel"> & {
25
+ onPointerDown: TimelineViewportProps["onPointerDown"];
26
+ };
27
+ setContainerRef: TimelineContainerProps["ref"];
28
+ setScrollRef: TimelineViewportProps["ref"];
29
+ focusProps: Pick<TimelineViewportProps, "onFocus" | "onBlur">;
30
+ elementCount: number;
31
+ activeTool: string;
32
+ shiftHeld: boolean;
33
+ labelMode: boolean;
34
+ contentOrigin: number;
35
+ zoomMode: "fit" | "manual";
36
+ theme: Pick<TimelineTheme, "shellBackground" | "shellBorder">;
37
+ razor: {
38
+ razorGuideX: number | null;
39
+ updateRazorGuide: TimelineContainerProps["onMouseMove"];
40
+ clearRazorGuide: () => void;
41
+ splitAllAtPointer: (event: PointerEvent<HTMLDivElement>) => boolean;
42
+ };
43
+ lastScrollLeftRef: MutableRefObject<number>;
44
+ recordTimelineScroll: (scroll: HTMLDivElement) => void;
45
+ syncScrollViewport: (scroll: HTMLDivElement, isScrolling?: boolean) => void;
46
+ }
47
+
48
+ /** The timeline shell's container and scroll viewport props: drop, pointer, scroll and razor wiring. */
49
+ export function buildTimelineShellMeta(input: TimelineShellMetaInputs): TimelineMeta {
50
+ const { drop, pointer, razor, theme, activeTool } = input;
51
+ return buildTimelineMeta({
52
+ emptyState: { isDragOver: input.isDragOver, onFileDrop: input.hasFileDrop, ...drop },
53
+ container: {
54
+ ref: input.setContainerRef,
55
+ "aria-label": "Timeline track view",
56
+ "data-timeline-element-count": input.elementCount,
57
+ isDragOver: input.isDragOver,
58
+ activeTool,
59
+ shiftHeld: input.shiftHeld,
60
+ accentClass: "ring-1 ring-inset ring-studio-accent/60",
61
+ onMouseMove: razor.updateRazorGuide,
62
+ onMouseLeave: razor.clearRazorGuide,
63
+ style: {
64
+ touchAction: "pan-x pan-y",
65
+ background: theme.shellBackground,
66
+ borderColor: theme.shellBorder,
67
+ },
68
+ },
69
+ viewport: {
70
+ ref: input.setScrollRef,
71
+ tabIndex: -1,
72
+ labelMode: input.labelMode,
73
+ contentOrigin: input.contentOrigin,
74
+ zoomMode: input.zoomMode,
75
+ onScroll: (e) => {
76
+ input.lastScrollLeftRef.current = e.currentTarget.scrollLeft;
77
+ input.recordTimelineScroll(e.currentTarget);
78
+ input.syncScrollViewport(e.currentTarget, true);
79
+ },
80
+ ...input.focusProps,
81
+ ...drop,
82
+ onPointerDown: (e) => {
83
+ if (shouldIgnoreTimelinePointerDown(e.target)) return;
84
+ if (razor.splitAllAtPointer(e)) return;
85
+ pointer.onPointerDown(e);
86
+ },
87
+ onPointerMove: pointer.onPointerMove,
88
+ onPointerUp: pointer.onPointerUp,
89
+ onPointerCancel: pointer.onPointerCancel,
90
+ onLostPointerCapture: pointer.onPointerCancel,
91
+ },
92
+ elementCount: input.elementCount,
93
+ labelColumnWidth: LABEL_COL_W,
94
+ razorGuide:
95
+ activeTool === "razor" && razor.razorGuideX !== null ? (
96
+ <TimelineRazorGuideOverlay x={razor.razorGuideX} />
97
+ ) : null,
98
+ });
99
+ }
@@ -0,0 +1,34 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import type { DraggedClipState } from "./timelineClipDragTypes";
4
+ import type { ResizingClipState } from "./useTimelineClipDrag";
5
+ import {
6
+ resolveMultiDragPreview,
7
+ resolveResizingElementIds,
8
+ } from "./timelineProviderStateBuilders";
9
+ import { getTimelinePreviewElement } from "./timelineViewModel";
10
+ import { resolveSnapGuide } from "./timelineSnapping";
11
+
12
+ /** What the lanes draw for a live move or trim: the ghost, the clips riding along, the snap guide. */
13
+ export function useTimelineDragPreviewState(
14
+ draggedClip: DraggedClipState | null,
15
+ resizingClip: ResizingClipState | null,
16
+ selectedElementIds: ReadonlySet<string>,
17
+ timelineElements: readonly TimelineElement[],
18
+ ) {
19
+ const getPreviewElement = useCallback(
20
+ (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
21
+ [resizingClip],
22
+ );
23
+ const multiDragPreview = useMemo(
24
+ () => resolveMultiDragPreview(draggedClip, selectedElementIds, timelineElements),
25
+ [draggedClip, selectedElementIds, timelineElements],
26
+ );
27
+ return {
28
+ resizingElementIds: resolveResizingElementIds(resizingClip),
29
+ getPreviewElement,
30
+ draggedElement: draggedClip?.element ?? null,
31
+ multiDragPreview,
32
+ snapGuide: resolveSnapGuide(draggedClip, resizingClip),
33
+ };
34
+ }
@@ -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,61 +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
- contentOrigin,
552
- zoomMode,
553
- onScroll: (e) => {
554
- lastScrollLeftRef.current = e.currentTarget.scrollLeft;
555
- recordTimelineScroll(e.currentTarget);
556
- syncScrollViewport(e.currentTarget, true);
557
- },
558
- ...timelineFocus.timelineFocusProps,
559
- onDragOver: assetDrop.handleAssetDragOver,
560
- onDragLeave: assetDrop.handleAssetDragLeave,
561
- onDrop: assetDrop.handleAssetDrop,
562
- onPointerDown: (e) => {
563
- if (shouldIgnoreTimelinePointerDown(e.target)) return;
564
- if (splitAllAtPointer(e)) return;
565
- handlePointerDown(e);
566
- },
520
+ pointer: {
521
+ onPointerDown: handlePointerDown,
567
522
  onPointerMove: handlePointerMove,
568
523
  onPointerUp: handlePointerUp,
569
524
  onPointerCancel: handlePointerCancel,
570
- onLostPointerCapture: handlePointerCancel,
571
525
  },
526
+ setContainerRef,
527
+ setScrollRef,
528
+ focusProps: timelineFocus.timelineFocusProps,
572
529
  elementCount: timelineElements.length,
573
- labelColumnWidth: LABEL_COL_W,
574
- razorGuide:
575
- activeTool === "razor" && razorGuideX !== null ? (
576
- <TimelineRazorGuideOverlay x={razorGuideX} />
577
- ) : 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,
578
540
  });
579
541
  const contextValue: TimelineContextValue = {
580
542
  state: {
@@ -353,7 +353,11 @@ describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
353
353
  describe("useTimelinePlayer RAF loop wrap-around", () => {
354
354
  type SeekCall = { time: number; options?: { keepPlaying?: boolean } };
355
355
 
356
- function attachInstrumentedAdapter(api: ReturnType<typeof useTimelinePlayer>, duration = 30) {
356
+ function attachInstrumentedAdapter(
357
+ api: ReturnType<typeof useTimelinePlayer>,
358
+ duration = 30,
359
+ hooks: object = {},
360
+ ) {
357
361
  let currentTime = 0;
358
362
  let playing = false;
359
363
  const seekCalls: SeekCall[] = [];
@@ -377,6 +381,7 @@ describe("useTimelinePlayer RAF loop wrap-around", () => {
377
381
  };
378
382
  attachIframeWindow(api, {
379
383
  __player: adapter,
384
+ __hf: hooks,
380
385
  postMessage: () => {},
381
386
  scrollTo: () => {},
382
387
  addEventListener: () => {},
@@ -446,6 +451,29 @@ describe("useTimelinePlayer RAF loop wrap-around", () => {
446
451
  }
447
452
  });
448
453
 
454
+ it("tells the preview where the loop wraps to while it plays, and that it stopped looping", () => {
455
+ const raf = installRafCapture();
456
+ try {
457
+ const { api, root } = renderTimelinePlayerHarness();
458
+ const setLoopStart = vi.fn();
459
+ const { adapter } = attachInstrumentedAdapter(api, 30, { setLoopStart });
460
+ act(() => {
461
+ usePlayerStore.getState().setInPoint(2);
462
+ usePlayerStore.getState().setOutPoint(5);
463
+ api.play();
464
+ });
465
+ adapter.setTime(3);
466
+ act(() => void raf.flushOne());
467
+ expect(setLoopStart).toHaveBeenLastCalledWith(2);
468
+ act(() => usePlayerStore.getState().setLoopEnabled(false));
469
+ act(() => void raf.flushOne());
470
+ expect(setLoopStart).toHaveBeenLastCalledWith(null);
471
+ unmountWithAct(root);
472
+ } finally {
473
+ raf.restore();
474
+ }
475
+ });
476
+
449
477
  it("does not seek and pauses cleanly when forward playback reaches the end without loop", () => {
450
478
  const raf = installRafCapture();
451
479
  try {
@@ -152,12 +152,18 @@ export function useTimelinePlayer({
152
152
  [],
153
153
  );
154
154
 
155
+ const setLoopStart = useCallback((seconds: number | null) => {
156
+ try {
157
+ (iframeRef.current?.contentWindow as IframeWindow | null)?.__hf?.setLoopStart?.(seconds);
158
+ } catch {}
159
+ }, []);
155
160
  const { startRAFLoop, stopRAFLoop, stopReverseLoop } = useTimelinePlayerLoop({
156
161
  rafRef,
157
162
  reverseRafRef,
158
163
  getAdapter,
159
164
  setCurrentTime,
160
165
  setIsPlaying,
166
+ setLoopStart,
161
167
  });
162
168
 
163
169
  const applyPlaybackRate = useCallback((rate: number) => {
@@ -22,6 +22,7 @@ interface UseTimelinePlayerLoopParams {
22
22
  getAdapter: () => PlaybackAdapter | null;
23
23
  setCurrentTime: (v: number) => void;
24
24
  setIsPlaying: (v: boolean) => void;
25
+ setLoopStart: (seconds: number | null) => void;
25
26
  }
26
27
 
27
28
  interface UseTimelinePlayerLoopResult {
@@ -36,6 +37,7 @@ export function useTimelinePlayerLoop({
36
37
  getAdapter,
37
38
  setCurrentTime,
38
39
  setIsPlaying,
40
+ setLoopStart,
39
41
  }: UseTimelinePlayerLoopParams): UseTimelinePlayerLoopResult {
40
42
  const stopReverseLoop = useCallback(() => {
41
43
  cancelAnimationFrame(reverseRafRef.current);
@@ -55,8 +57,10 @@ export function useTimelinePlayerLoop({
55
57
  const rawLoopStart = inPoint !== null ? inPoint : 0;
56
58
  const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
57
59
  const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
60
+ const looping = usePlayerStore.getState().loopEnabled && dur > 0;
61
+ setLoopStart(looping ? loopStart : null);
58
62
  if (time >= loopEnd) {
59
- if (usePlayerStore.getState().loopEnabled && dur > 0) {
63
+ if (looping) {
60
64
  // keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
61
65
  adapter.seek(loopStart, { keepPlaying: true });
62
66
  liveTime.notify(loopStart);
@@ -75,7 +79,7 @@ export function useTimelinePlayerLoop({
75
79
  rafRef.current = requestAnimationFrame(tick);
76
80
  };
77
81
  rafRef.current = requestAnimationFrame(tick);
78
- }, [rafRef, getAdapter, setCurrentTime, setIsPlaying]);
82
+ }, [rafRef, getAdapter, setCurrentTime, setIsPlaying, setLoopStart]);
79
83
 
80
84
  const stopRAFLoop = useCallback(() => {
81
85
  cancelAnimationFrame(rafRef.current);
@@ -69,5 +69,6 @@ export type IframeWindow = Window & {
69
69
  __hf?: {
70
70
  leasePausedMedia?: (el: HTMLMediaElement) => void;
71
71
  releasePausedMedia?: (el: HTMLMediaElement) => void;
72
+ setLoopStart?: (seconds: number | null) => void;
72
73
  };
73
74
  };
@@ -99,6 +99,54 @@ describe("applyTrackRenumbers", () => {
99
99
  });
100
100
 
101
101
  describe("resolveDropTrack", () => {
102
+ // Title shows on row 0; Music is written on track 0 too but shows on audio row 1.
103
+ const shown = (
104
+ id: string,
105
+ tag: string,
106
+ row: number,
107
+ track: number,
108
+ start: number,
109
+ length: number,
110
+ ) => ({
111
+ ...clip(id, row, start),
112
+ tag,
113
+ duration: length,
114
+ authoredTrack: track,
115
+ });
116
+ const title = shown("title", "div", 0, 0, 0, 10);
117
+ const music = shown("music", "audio", 1, 0, 2, 3);
118
+ const song = { id: "song", tag: "audio", start: 2, duration: 3 };
119
+ const drop = (elements: TimelineElement[], row: number, dropped = song) =>
120
+ resolveDropTrack({ source, elements, placement: { track: row }, dropped });
121
+
122
+ it("joins an audio row's file track at the nearest free time, not a new track", () => {
123
+ expect(drop([title, music], 1)).toMatchObject({ track: 0, start: 5 });
124
+ });
125
+
126
+ it("keeps an audio file dropped on a visual row off the audio clip on the track it is written to", () => {
127
+ expect(drop([title, music], 0)).toMatchObject({ track: 0, start: 5 });
128
+ });
129
+
130
+ it("writes a drop on a shown row to that row's file track and checks the clips there", () => {
131
+ const top = shown("top", "div", 0, 0, 0, 4);
132
+ const lower = shown("lower", "div", 1, 2, 0, 4); // file track 2, shown as row 1
133
+ const image = { id: "image", tag: "img", start: 1, duration: 3 };
134
+ expect(drop([top, lower], 1, image)).toMatchObject({ track: 2, start: 4 });
135
+ });
136
+
137
+ it("lets only clips of the dropped file's kind block it on the row", () => {
138
+ const title = clip("title", 0, 0);
139
+ const music = { ...clip("music", 0, 0), tag: "audio" };
140
+ const song = { id: "song", tag: "audio", start: 1, duration: 3 };
141
+ // An audio file shows in the audio rows wherever it is written, so a visual clip never blocks it.
142
+ expect(
143
+ resolveDropTrack({ source, elements: [title], placement: { track: 0 }, dropped: song }).start,
144
+ ).toBe(1);
145
+ expect(
146
+ resolveDropTrack({ source, elements: [music], placement: { track: 0 }, dropped: song }).start,
147
+ ).toBe(2);
148
+ });
149
+
102
150
  it("keeps the aimed lane and the source when no insert is asked for", () => {
103
151
  const out = resolveDropTrack({
104
152
  source,
@@ -106,7 +154,17 @@ describe("resolveDropTrack", () => {
106
154
  placement: { track: 2 },
107
155
  dropped,
108
156
  });
109
- expect(out).toEqual({ source, track: 2 });
157
+ expect(out).toEqual({ source, track: 2, start: 5 });
158
+ });
159
+
160
+ it("moves a drop on an occupied row to that row's nearest free time, with no new track", () => {
161
+ const out = resolveDropTrack({
162
+ source,
163
+ elements,
164
+ placement: { track: 1 },
165
+ dropped: { ...dropped, start: 1 },
166
+ });
167
+ expect(out).toEqual({ source, track: 1, start: 2 });
110
168
  });
111
169
 
112
170
  it("returns the new lane and the source with the lanes below pushed down", () => {
@@ -117,6 +175,7 @@ describe("resolveDropTrack", () => {
117
175
  dropped,
118
176
  });
119
177
  expect(out.track).toBe(1);
178
+ expect(out.start).toBe(5);
120
179
  expect(out.source).toContain('id="b" data-start="0" data-track-index="2"');
121
180
  });
122
181
 
@@ -1,6 +1,9 @@
1
1
  import type { TimelineElement } from "../player";
2
2
  import { layoutAfterTrackInsert } from "../player/components/timelineDragLanding";
3
3
  import { canMoveTimelineElement } from "../player/components/timelineAuthoredMoveTarget";
4
+ import { resolveNearestFreeStart } from "../player/components/timelineCollision";
5
+ import { authoredTrackForLane } from "../player/components/timelineAuthoredTrack";
6
+ import { isAudioTimelineElement } from "./timelineInspector";
4
7
  import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
5
8
  import { applyPatchByTarget, readAttributeByTarget } from "./sourcePatcher";
6
9
  import { buildPatchTarget } from "../hooks/timelineEditingHelpers";
@@ -67,17 +70,45 @@ export function applyTrackRenumbers(source: string, plan: DropTrackInsertPlan):
67
70
  return out;
68
71
  }
69
72
 
70
- /** The lane a dropped clip is written on and the source with any lanes pushed down to make room. */
73
+ type DroppedClip = Pick<TimelineElement, "id" | "tag" | "start" | "duration">;
74
+
75
+ /** The file track a drop on display row `lane` is written to, and its nearest free start there.
76
+ * The timeline's own clips decide the track; `placed` clips only block, like same-kind clips there. */
77
+ function resolveRowDrop(
78
+ elements: TimelineElement[],
79
+ placed: readonly TimelineElement[],
80
+ lane: number,
81
+ dropped: DroppedClip,
82
+ ): { track: number; start: number } {
83
+ const audio = isAudioTimelineElement(dropped);
84
+ const row = elements.filter((e) => e.track === lane);
85
+ const otherKindRow = row.length > 0 && !row.some((e) => isAudioTimelineElement(e) === audio);
86
+ const asClip = { ...dropped, key: dropped.id, track: lane, sourceFile: elements[0]?.sourceFile };
87
+ const track = otherKindRow ? lane : authoredTrackForLane(lane, elements, asClip);
88
+ const onTrack = [...elements, ...placed]
89
+ .filter((e) => isAudioTimelineElement(e) === audio && (e.authoredTrack ?? e.track) === track)
90
+ .map((e) => ({ ...e, track }));
91
+ return {
92
+ track,
93
+ start: resolveNearestFreeStart(onTrack, track, dropped.start, dropped.duration, null),
94
+ };
95
+ }
96
+
97
+ /** Where a dropped clip is written: the aimed row's file track at its nearest free time, or a new track. */
71
98
  export function resolveDropTrack(input: {
72
99
  source: string;
73
100
  elements: TimelineElement[];
101
+ /** Clips this drop gesture already wrote, which `elements` does not hold yet. */
102
+ placed?: readonly TimelineElement[];
74
103
  placement: TimelineDropPlacement;
75
- dropped: Pick<TimelineElement, "id" | "tag" | "start" | "duration">;
76
- }): { source: string; track: number } {
104
+ dropped: DroppedClip;
105
+ }): { source: string; track: number; start: number } {
77
106
  const { source, elements, placement, dropped } = input;
78
- if (placement.insertRow == null) return { source, track: placement.track };
107
+ if (placement.insertRow == null) {
108
+ return { source, ...resolveRowDrop(elements, input.placed ?? [], placement.track, dropped) };
109
+ }
79
110
  const { insertRow, trackOrder } = placement;
80
111
  const plan = planDropTrackInsert({ elements, trackOrder, insertRow, dropped });
81
112
  if (!plan) throw new Error("Cannot open a new track here: a locked clip would have to move.");
82
- return { source: applyTrackRenumbers(source, plan), track: plan.track };
113
+ return { source: applyTrackRenumbers(source, plan), track: plan.track, start: dropped.start };
83
114
  }