@hyperframes/studio 0.8.120 → 0.8.122

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 (54) hide show
  1. package/dist/assets/{hyperframes-player-i4gqkhjz.js → hyperframes-player-DMzBHhbH.js} +1 -1
  2. package/dist/assets/{index-Ce3Lr-Gt.js → index-B-YDp8hN.js} +174 -174
  3. package/dist/assets/{index-uWOhRqxe.js → index-CBRX2eZX.js} +1 -1
  4. package/dist/assets/{index-C7LHDM18.js → index-lyjhSEzi.js} +1 -1
  5. package/dist/{chunk-J6Q572FA.js → chunk-246VB2G3.js} +3 -1
  6. package/dist/chunk-246VB2G3.js.map +1 -0
  7. package/dist/index.d.ts +10 -2
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +416 -301
  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 +25 -0
  14. package/src/components/TimelineToolbar.tsx +5 -1
  15. package/src/components/editor/MotionPathNode.tsx +2 -1
  16. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +99 -0
  17. package/src/components/editor/MotionPathOverlay.tsx +6 -0
  18. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +57 -0
  19. package/src/components/editor/domEditOverlayStartGesture.ts +1 -1
  20. package/src/components/editor/motionPathGeometry.test.ts +10 -0
  21. package/src/components/editor/motionPathGeometry.ts +8 -5
  22. package/src/components/editor/motionPathLayerNode.test.ts +82 -0
  23. package/src/components/editor/motionPathLayerNode.ts +28 -0
  24. package/src/components/editor/useDomEditNudge.test.tsx +53 -1
  25. package/src/components/editor/useDomEditNudge.ts +2 -2
  26. package/src/components/editor/useMotionPathData.test.tsx +30 -0
  27. package/src/components/editor/useMotionPathData.ts +3 -1
  28. package/src/hooks/gsapValueAtPlayhead.test.ts +46 -3
  29. package/src/hooks/gsapValueAtPlayhead.ts +12 -2
  30. package/src/hooks/linkShortcuts.test.ts +16 -0
  31. package/src/hooks/linkShortcuts.ts +2 -0
  32. package/src/hooks/useAppHotkeys.ts +1 -2
  33. package/src/index.ts +2 -0
  34. package/src/player/components/TimelineClipFades.tsx +1 -0
  35. package/src/player/components/timelineClipDragPreview.test.ts +68 -0
  36. package/src/player/components/timelineClipDragPreview.ts +39 -14
  37. package/src/player/components/timelineLayout.test.ts +24 -1
  38. package/src/player/components/timelineRulerGeometry.ts +24 -3
  39. package/src/player/components/timelineSnapping.test.ts +43 -12
  40. package/src/player/components/timelineSnapping.ts +33 -15
  41. package/src/player/components/useTimelineClipDrag.resize.test.tsx +54 -54
  42. package/src/player/components/useTimelineClipDrag.ts +17 -29
  43. package/src/player/components/useTimelineProviderState.tsx +0 -1
  44. package/src/player/components/useTimelineTicks.ts +6 -3
  45. package/src/player/hooks/timelinePlayerTestHarness.ts +2 -0
  46. package/src/player/hooks/useTimelinePlayer.seek.test.ts +66 -0
  47. package/src/player/hooks/useTimelinePlayer.ts +6 -2
  48. package/src/player/lib/time.ts +3 -0
  49. package/src/player/store/previewFrameStore.ts +48 -0
  50. package/src/player/store/timelineElement.ts +12 -0
  51. package/src/timelineChromeExports.test.tsx +6 -0
  52. package/src/utils/gsapSoftReload.test.ts +50 -0
  53. package/src/utils/gsapSoftReload.ts +22 -2
  54. package/dist/chunk-J6Q572FA.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
+ import { roundToCenti } from "../../utils/rounding";
2
3
 
3
- export type TimelineSnapType = "beat" | "playhead" | "clip-edge";
4
+ export type TimelineSnapType = "beat" | "playhead" | "clip-edge" | "grid";
4
5
 
5
6
  export interface TimelineSnapTarget {
6
7
  time: number;
@@ -24,6 +25,7 @@ const TYPE_PRIORITY: Record<TimelineSnapType, number> = {
24
25
  playhead: 0,
25
26
  "clip-edge": 1,
26
27
  beat: 2,
28
+ grid: 3,
27
29
  };
28
30
 
29
31
  export function collectTimelineSnapTargets(input: {
@@ -31,8 +33,6 @@ export function collectTimelineSnapTargets(input: {
31
33
  playheadTime: number | null;
32
34
  beatTimes: readonly number[];
33
35
  excludeElementKey?: string | null;
34
- /** A trim excludes the playhead: the dragged edge drives it, so snapping to it is circular. */
35
- includePlayhead?: boolean;
36
36
  }): TimelineSnapTarget[] {
37
37
  const byTime = new Map<number, TimelineSnapTarget>();
38
38
  const add = (time: number, type: TimelineSnapType) => {
@@ -50,16 +50,30 @@ export function collectTimelineSnapTargets(input: {
50
50
  add(el.start, "clip-edge");
51
51
  add(el.start + el.duration, "clip-edge");
52
52
  }
53
- if (input.playheadTime != null && input.includePlayhead !== false)
54
- add(input.playheadTime, "playhead");
53
+ if (input.playheadTime != null) add(input.playheadTime, "playhead");
55
54
 
56
55
  return Array.from(byTime.values()).sort((a, b) => a.time - b.time);
57
56
  }
58
57
 
58
+ /** Nearest ruler line in range, at its saved centisecond, and only within a pixel of it. */
59
+ function nearestGridLine(
60
+ time: number,
61
+ gridStep: number,
62
+ thresholdSecs: number,
63
+ ): TimelineSnapTarget | null {
64
+ if (!(gridStep > 0)) return null;
65
+ const line = Math.round(time / gridStep) * gridStep;
66
+ const saved = roundToCenti(line);
67
+ const pixelsPerSecond = TIMELINE_SNAP_PX / thresholdSecs;
68
+ if (Math.abs(saved - line) * pixelsPerSecond >= 1) return null;
69
+ return Math.abs(saved - time) < thresholdSecs ? { time: saved, type: "grid" } : null;
70
+ }
71
+
59
72
  export function snapTimelineTime(
60
73
  time: number,
61
74
  targets: readonly TimelineSnapTarget[],
62
75
  thresholdSecs: number,
76
+ gridStep = 0,
63
77
  ): { time: number; target: TimelineSnapTarget | null } {
64
78
  let best: TimelineSnapTarget | null = null;
65
79
  let bestDist = thresholdSecs;
@@ -73,6 +87,7 @@ export function snapTimelineTime(
73
87
  best = target;
74
88
  }
75
89
  }
90
+ best ??= nearestGridLine(time, gridStep, thresholdSecs);
76
91
  return best ? { time: best.time, target: best } : { time, target: null };
77
92
  }
78
93
 
@@ -88,23 +103,26 @@ export function snapMoveToTargets(
88
103
  targets: readonly TimelineSnapTarget[],
89
104
  pixelsPerSecond: number,
90
105
  timelineDuration: number,
106
+ gridStep = 0,
91
107
  ): { start: number; snapTime: number | null; snapType: TimelineSnapType | null } {
92
- if (targets.length === 0) return { start, snapTime: null, snapType: null };
108
+ if (targets.length === 0 && !(gridStep > 0)) return { start, snapTime: null, snapType: null };
93
109
  const thresholdSecs = TIMELINE_SNAP_PX / Math.max(pixelsPerSecond, 1);
94
- const startSnap = snapTimelineTime(start, targets, thresholdSecs);
95
- const endSnap = snapTimelineTime(start + duration, targets, thresholdSecs);
96
- const startMoved = startSnap.target !== null;
97
- const endMoved = endSnap.target !== null;
110
+ const startSnap = snapTimelineTime(start, targets, thresholdSecs, gridStep);
111
+ const endSnap = snapTimelineTime(start + duration, targets, thresholdSecs, gridStep);
112
+ // The nearer edge wins, but a grid line only when neither edge has a real target.
113
+ const rank = (snap: typeof startSnap, edgeTime: number) =>
114
+ snap.target === null
115
+ ? Infinity
116
+ : Math.abs(snap.time - edgeTime) + (snap.target.type === "grid" ? thresholdSecs : 0);
117
+ const startRank = rank(startSnap, start);
118
+ const endRank = rank(endSnap, start + duration);
98
119
 
99
120
  let candidate = start;
100
121
  let target: TimelineSnapTarget | null = null;
101
- if (
102
- startMoved &&
103
- (!endMoved || Math.abs(startSnap.time - start) <= Math.abs(endSnap.time - (start + duration)))
104
- ) {
122
+ if (startRank !== Infinity && startRank <= endRank) {
105
123
  candidate = startSnap.time;
106
124
  target = startSnap.target;
107
- } else if (endMoved) {
125
+ } else if (endRank !== Infinity) {
108
126
  candidate = endSnap.time - duration;
109
127
  target = endSnap.target;
110
128
  }
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { TimelineElement } from "../store/playerStore";
6
6
  import { usePlayerStore } from "../store/playerStore";
7
+ import { usePreviewFrameStore } from "../store/previewFrameStore";
7
8
  import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
8
9
  import { useTimelineClipDrag } from "./useTimelineClipDrag";
9
10
  import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
@@ -26,6 +27,7 @@ function el(id: string, over: Partial<TimelineElement> = {}): TimelineElement {
26
27
  afterEach(() => {
27
28
  document.body.innerHTML = "";
28
29
  usePlayerStore.getState().reset();
30
+ usePreviewFrameStore.setState({ time: null });
29
31
  });
30
32
 
31
33
  function renderResizeHarness(
@@ -34,7 +36,6 @@ function renderResizeHarness(
34
36
  options: {
35
37
  wireGroupResize?: boolean;
36
38
  snap?: boolean;
37
- onSeek?: (time: number, seekOptions?: { keepPlaying?: boolean }) => void;
38
39
  } = {},
39
40
  ) {
40
41
  usePlayerStore.getState().setElements(elements);
@@ -65,7 +66,6 @@ function renderResizeHarness(
65
66
  onMoveElement,
66
67
  onBlockedEditAttempt,
67
68
  onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements,
68
- onSeek: options.onSeek,
69
69
  setShowPopover: vi.fn(),
70
70
  setRangeSelectionRef: { current: vi.fn() },
71
71
  sessionEpoch,
@@ -451,16 +451,7 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
451
451
  });
452
452
 
453
453
  describe("useTimelineClipDrag — trim guide and preview frame", () => {
454
- /** Playhead at 1.25s that follows preview seeks; clip a's end edge grabbed and dragged once. */
455
- function trimEndWithSeekingPlayhead(state: { isPlaying?: boolean } = {}) {
456
- usePlayerStore.setState({ currentTime: 1.25, ...state });
457
- const onSeek = vi.fn((t: number) => usePlayerStore.setState({ currentTime: t }));
458
- const a = el("a", { start: 1, duration: 2 });
459
- const h = renderResizeHarness([a], [], { onSeek });
460
- h.startResize(a, "end");
461
- h.movePointer(50);
462
- return { onSeek, h };
463
- }
454
+ const previewFrame = () => usePreviewFrameStore.getState().time;
464
455
 
465
456
  /** Clip a (0-2s) with neighbour b starting at 5s, snapping on, a's end edge grabbed. */
466
457
  function trimAEndBesideB() {
@@ -487,45 +478,78 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
487
478
 
488
479
  it("publishes no snap target when the trimmed edge is free", () => {
489
480
  const h = trimAEndBesideB();
490
- h.movePointer(150);
481
+ h.movePointer(162); // 3.62s: 12px from the 3.5s ruler line, 13px from 3.75s
491
482
  expect(h.getResizingClip()).toMatchObject({ snapTime: null, snapType: null });
492
483
  h.unmount();
493
484
  });
494
485
 
495
- it("never snaps a trimmed edge to the playhead — the edge drives its own seek", () => {
496
- usePlayerStore.setState({ currentTime: 5 }); // no clip edge or beat nearby, only the playhead
486
+ it("snaps a trimmed edge to the playhead, which stays where it was", () => {
487
+ usePlayerStore.setState({ currentTime: 5.1 }); // off the ruler grid; no clip edge or beat nearby
497
488
  const a = el("a", { start: 0, duration: 2 });
498
489
  const h = renderResizeHarness([a], [], { snap: true });
499
490
  h.startResize(a, "end");
500
- h.movePointer(296); // a's end lands at 4.96s, 4px from the playhead
491
+ h.movePointer(306); // a's end lands at 5.06s, 4px from the playhead
492
+ expect(h.getResizingClip()).toMatchObject({
493
+ snapTime: 5.1,
494
+ snapType: "playhead",
495
+ previewDuration: 5.1,
496
+ });
497
+ expect(usePlayerStore.getState().currentTime).toBe(5.1);
498
+ h.unmount();
499
+ });
500
+
501
+ it("snaps a trimmed edge to the ruler's grid line when no clip edge is near", () => {
502
+ const h = trimAEndBesideB();
503
+ h.movePointer(152); // 3.52s, 2px from the 3.5s line (0.25s apart at 100px/s)
501
504
  expect(h.getResizingClip()).toMatchObject({
502
- snapTime: null,
503
- snapType: null,
504
- previewDuration: 4.96,
505
+ snapTime: 3.5,
506
+ snapType: "grid",
507
+ previewDuration: 3.5,
505
508
  });
506
509
  h.unmount();
507
510
  });
508
511
 
509
- it("shows the frame at the dragged edge, then puts the playhead back on release", async () => {
512
+ it("does not snap to the grid with the magnet off", () => {
513
+ const a = el("a", { start: 0, duration: 2 });
514
+ const h = renderResizeHarness([a], []);
515
+ h.startResize(a, "end");
516
+ h.movePointer(152);
517
+ expect(h.getResizingClip()).toMatchObject({ snapTime: null, previewDuration: 3.52 });
518
+ h.unmount();
519
+ });
520
+
521
+ it("shows the dragged edge's frame without moving the playhead, and drops it on release", async () => {
510
522
  usePlayerStore.setState({ currentTime: 1.25 });
511
- const onSeek = vi.fn();
512
523
  const a = el("a", { start: 1, duration: 2 });
513
- const h = renderResizeHarness([a], [], { onSeek });
524
+ const h = renderResizeHarness([a], []);
514
525
  h.startResize(a, "end");
515
526
  h.movePointer(50);
516
- expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30, { keepPlaying: true, follow: false });
527
+ expect(previewFrame()).toBeCloseTo(3.5 - 1 / 30, 6);
528
+ h.movePointer(120);
529
+ expect(usePlayerStore.getState().currentTime).toBe(1.25);
517
530
  await h.dropPointer();
518
- expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true, follow: false });
531
+ expect(previewFrame()).toBeNull();
532
+ expect(usePlayerStore.getState().currentTime).toBe(1.25);
533
+ h.unmount();
534
+ });
535
+
536
+ it("drops the preview frame when the trim is cancelled", () => {
537
+ const a = el("a", { start: 1, duration: 2 });
538
+ const h = renderResizeHarness([a], []);
539
+ h.startResize(a, "end");
540
+ h.movePointer(50);
541
+ expect(previewFrame()).not.toBeNull();
542
+ h.pressEscape();
543
+ expect(previewFrame()).toBeNull();
519
544
  h.unmount();
520
545
  });
521
546
 
522
547
  it("previews the new in-point when the start edge is trimmed", () => {
523
- const onSeek = vi.fn();
524
548
  const a = el("a", { start: 1, duration: 2 });
525
- const h = renderResizeHarness([a], [], { onSeek });
549
+ const h = renderResizeHarness([a], []);
526
550
  h.startResize(a, "start");
527
551
  h.movePointer(50);
528
- expect(onSeek).toHaveBeenLastCalledWith(1.5, { keepPlaying: true, follow: false });
552
+ expect(previewFrame()).toBe(1.5);
529
553
  h.unmount();
530
554
  });
531
555
 
@@ -540,41 +564,17 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
540
564
  h.unmount();
541
565
  });
542
566
 
543
- it("restores the playhead it had before the first preview seek, not a seeked time", async () => {
544
- const { onSeek, h } = trimEndWithSeekingPlayhead();
545
- h.movePointer(80);
546
- h.movePointer(120);
547
- await h.dropPointer();
548
- expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true, follow: false });
549
- h.unmount();
550
- });
551
-
552
- it("leaves the playhead where playback is when a trim is released while playing", async () => {
553
- const { onSeek, h } = trimEndWithSeekingPlayhead({ isPlaying: true });
554
- h.movePointer(120);
555
- const callsBeforeRelease = onSeek.mock.calls.length;
556
- await h.dropPointer();
557
- expect(onSeek).toHaveBeenCalledTimes(callsBeforeRelease);
558
- expect(onSeek).not.toHaveBeenCalledWith(1.25, expect.anything());
559
- usePlayerStore.setState({ isPlaying: false });
560
- h.unmount();
561
- });
562
-
563
- it("draws no guide and seeks to the rendered edge when a group member clamps the trim", () => {
564
- const onSeek = vi.fn();
567
+ it("draws no guide and previews the rendered edge when a group member clamps the trim", () => {
565
568
  const a = el("a", { start: 0, duration: 4 });
566
569
  const b = el("b", { start: 0, duration: 1 });
567
570
  const c = el("c", { start: 2, duration: 1 });
568
- const h = renderResizeHarness([a, b, c], ["a", "b"], { snap: true, onSeek });
571
+ const h = renderResizeHarness([a, b, c], ["a", "b"], { snap: true });
569
572
  h.startResize(a, "end");
570
573
  h.movePointer(-198); // a's raw end 2.02s snaps to c at 2s; b clamps the shared delta
571
574
  const clip = h.getResizingClip()!;
572
575
  expect(clip.previewStart + clip.previewDuration).toBeCloseTo(3.1, 3);
573
576
  expect(clip).toMatchObject({ snapTime: null, snapType: null });
574
- expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3), {
575
- keepPlaying: true,
576
- follow: false,
577
- });
577
+ expect(previewFrame()).toBeCloseTo(3.1 - 1 / 30, 3);
578
578
  h.unmount();
579
579
  });
580
580
  });
@@ -12,6 +12,8 @@ import {
12
12
  type TimelineGroupResizeSession,
13
13
  } from "./timelineGroupEditing";
14
14
  import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineSnapping";
15
+ import { getTimelineGridStep, rulerFrameRate } from "./timelineRulerGeometry";
16
+ import { setPreviewFrame } from "../store/previewFrameStore";
15
17
  import type { StackingPatch } from "./timelineStackingSync";
16
18
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
17
19
  import {
@@ -76,8 +78,6 @@ interface UseTimelineClipDragInput {
76
78
  onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
77
79
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
78
80
  onLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
79
- /** Seeks the preview; a trim shows the frame at its dragged edge. */
80
- onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
81
81
  setShowPopover: (show: boolean) => void;
82
82
  /** Stable ref to the range selection setter — wired after mount to break circular dependency. */
83
83
  setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
@@ -108,7 +108,6 @@ export function useTimelineClipDrag({
108
108
  onResizeElements,
109
109
  onBlockedEditAttempt,
110
110
  onLinkEdit,
111
- onSeek,
112
111
  setShowPopover,
113
112
  setRangeSelectionRef,
114
113
  readZIndex,
@@ -165,15 +164,11 @@ export function useTimelineClipDrag({
165
164
  const dragAudioTracksRef = useRef<ReadonlySet<number> | null>(null);
166
165
 
167
166
  const buildSnapTargets = useCallback(
168
- (
169
- excludeElementKey: string | null,
170
- includeBeats: boolean,
171
- includePlayhead = true,
172
- ): TimelineSnapTarget[] => {
167
+ (excludeElementKey: string | null, includeBeats: boolean): TimelineSnapTarget[] => {
173
168
  // Magnet off ⇒ no targets and no scan; do NOT cache so a mid-gesture toggle
174
169
  // back on starts scanning immediately (preserves the existing skip).
175
170
  if (!snapContextRef.current.enabled) return [];
176
- const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}|${includePlayhead ? 1 : 0}`;
171
+ const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}`;
177
172
  const cached = snapTargetsCacheRef.current.get(cacheKey);
178
173
  if (cached) return cached;
179
174
  const targets = collectTimelineSnapTargets({
@@ -181,13 +176,18 @@ export function useTimelineClipDrag({
181
176
  playheadTime: usePlayerStore.getState().currentTime,
182
177
  beatTimes: includeBeats ? snapContextRef.current.beatTimes : [],
183
178
  excludeElementKey,
184
- includePlayhead,
185
179
  });
186
180
  snapTargetsCacheRef.current.set(cacheKey, targets);
187
181
  return targets;
188
182
  },
189
183
  [],
190
184
  );
185
+ // The ruler's line spacing at the current zoom; 0 with the magnet off, like the targets.
186
+ const snapGridStep = useCallback(() => {
187
+ if (!snapContextRef.current.enabled) return 0;
188
+ const frameRate = rulerFrameRate(usePlayerStore.getState().timeDisplayMode);
189
+ return getTimelineGridStep(durationRef.current, ppsRef.current, frameRate);
190
+ }, [durationRef, ppsRef]);
191
191
 
192
192
  const [draggedClip, setDraggedClipState] = useState<DraggedClipState | null>(null);
193
193
  const draggedClipRef = useRef<DraggedClipState | null>(null);
@@ -285,10 +285,6 @@ export function useTimelineClipDrag({
285
285
  onResizeElementRef.current = onResizeElement;
286
286
  const onResizeElementsRef = useRef(onResizeElements);
287
287
  onResizeElementsRef.current = onResizeElements;
288
- const onSeekRef = useRef(onSeek);
289
- onSeekRef.current = onSeek;
290
- // Playhead time before the first trim preview seek; restored when the gesture ends.
291
- const trimSeekOriginRef = useRef<number | null>(null);
292
288
  const readZIndexRef = useRef(readZIndex);
293
289
  readZIndexRef.current = readZIndex;
294
290
  const onStackingPatchesRef = useRef(onStackingPatches);
@@ -325,6 +321,7 @@ export function useTimelineClipDrag({
325
321
  selectedKeys: gestureSelectedKeysRef.current,
326
322
  buildSnapTargets,
327
323
  audioTracks: dragAudioTracksRef.current,
324
+ gridStep: snapGridStep(),
328
325
  });
329
326
  },
330
327
  [
@@ -335,6 +332,7 @@ export function useTimelineClipDrag({
335
332
  trackInsertLayoutRef,
336
333
  rowGeometryRef,
337
334
  buildSnapTargets,
335
+ snapGridStep,
338
336
  ],
339
337
  );
340
338
 
@@ -349,15 +347,11 @@ export function useTimelineClipDrag({
349
347
  buildSnapTargets,
350
348
  elements: elementsRef.current,
351
349
  gestureKeys: gestureSelectedKeysRef.current,
350
+ gridStep: snapGridStep(),
352
351
  });
353
- trimSeekOriginRef.current ??= usePlayerStore.getState().currentTime;
354
352
  const setResizeState = (v: ResizePreviewResult) => {
355
- // A trim never changes the play state: keepPlaying lets seek() decide,
356
- // and it only resumes playback if it was already playing.
357
- onSeekRef.current?.(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration), {
358
- keepPlaying: true,
359
- follow: false,
360
- });
353
+ // The preview shows the dragged edge's frame; the playhead stays where it was.
354
+ setPreviewFrame(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration));
361
355
  publishResizingClip(
362
356
  resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
363
357
  );
@@ -393,7 +387,7 @@ export function useTimelineClipDrag({
393
387
  }
394
388
  previewGroupResize(session, next, setResizeState);
395
389
  },
396
- [scrollRef, ppsRef, buildSnapTargets, publishResizingClip],
390
+ [scrollRef, ppsRef, buildSnapTargets, snapGridStep, publishResizingClip],
397
391
  );
398
392
  const applyResizePointerRef = useRef(applyResizePointer);
399
393
  applyResizePointerRef.current = applyResizePointer;
@@ -404,13 +398,7 @@ export function useTimelineClipDrag({
404
398
  cancelAnimationFrame(clipDragScrollRaf.current);
405
399
  clipDragScrollRaf.current = 0;
406
400
  }
407
- if (trimSeekOriginRef.current != null) {
408
- // Paused: put the playhead back. Playing: leave it, a backward jump would rewind live playback.
409
- if (!usePlayerStore.getState().isPlaying) {
410
- onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true, follow: false });
411
- }
412
- trimSeekOriginRef.current = null;
413
- }
401
+ setPreviewFrame(null);
414
402
  // Gesture teardown: drop frozen caches so the next gesture reads fresh state.
415
403
  snapTargetsCacheRef.current.clear();
416
404
  dragAudioTracksRef.current = null;
@@ -223,7 +223,6 @@ export function useTimelineProviderState({
223
223
  onResizeElements: pinnedOnResizeElements,
224
224
  onBlockedEditAttempt: oneShotEdits.onBlockedEditAttempt,
225
225
  onLinkEdit: oneShotEdits.onLinkEdit,
226
- onSeek,
227
226
  setShowPopover,
228
227
  setRangeSelectionRef,
229
228
  readZIndex: zSyncEnabled ? readClipZIndex : undefined,
@@ -1,8 +1,11 @@
1
1
  import { useMemo } from "react";
2
- import { STUDIO_PREVIEW_FPS } from "../lib/time";
3
2
  import type { TimelineTimeDisplayMode } from "../../utils/studioUiPreferences";
4
3
  import type { TimelineTimeRange } from "../lib/timelineClipIndex";
5
- import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
4
+ import {
5
+ generateTicks,
6
+ getTimelineMajorTickInterval,
7
+ rulerFrameRate,
8
+ } from "./timelineRulerGeometry";
6
9
 
7
10
  export function useTimelineTicks(
8
11
  duration: number,
@@ -10,7 +13,7 @@ export function useTimelineTicks(
10
13
  timeDisplayMode: TimelineTimeDisplayMode,
11
14
  renderTimeRange?: TimelineTimeRange,
12
15
  ) {
13
- const frameRate = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
16
+ const frameRate = rulerFrameRate(timeDisplayMode);
14
17
  const ticks = useMemo(
15
18
  () => generateTicks(duration, pixelsPerSecond, frameRate, renderTimeRange),
16
19
  [duration, frameRate, pixelsPerSecond, renderTimeRange],
@@ -5,12 +5,14 @@ import { createRoot } from "react-dom/client";
5
5
  import { vi } from "vitest";
6
6
  import { useTimelinePlayer, type UseTimelinePlayerOptions } from "./useTimelinePlayer";
7
7
  import { usePlayerStore } from "../store/playerStore";
8
+ import { setPreviewFrame } from "../store/previewFrameStore";
8
9
 
9
10
  export type TimelinePlayerApi = ReturnType<typeof useTimelinePlayer>;
10
11
 
11
12
  export function resetPlayerStore() {
12
13
  usePlayerStore.getState().reset();
13
14
  usePlayerStore.setState({ requestedSeekTime: null });
15
+ setPreviewFrame(null);
14
16
  }
15
17
 
16
18
  function TimelinePlayerHarness({
@@ -12,6 +12,7 @@ import {
12
12
  resetPlayerStore,
13
13
  } from "./timelinePlayerTestHarness";
14
14
  import { liveTime, usePlayerStore } from "../store/playerStore";
15
+ import { setPreviewFrame } from "../store/previewFrameStore";
15
16
  import { setTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
16
17
 
17
18
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -350,6 +351,71 @@ describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
350
351
  });
351
352
  });
352
353
 
354
+ describe("useTimelinePlayer preview frame (a trim's dragged edge)", () => {
355
+ function previewFrame(time: number | null) {
356
+ act(() => setPreviewFrame(time));
357
+ }
358
+
359
+ it("shows the frame without moving the playhead, then puts the playhead's frame back", () => {
360
+ const { api, root, adapter } = renderAttachedTimelinePlayer();
361
+ seekWithAct(api, 1.25);
362
+ const seeks = liveTime.seekCount();
363
+ previewFrame(3.4);
364
+ expect(adapter.getTime()).toBe(3.4);
365
+ expect(usePlayerStore.getState().currentTime).toBe(1.25);
366
+ expect(liveTime.latest()).toBe(1.25);
367
+ previewFrame(null);
368
+ expect(adapter.getTime()).toBe(1.25);
369
+ expect(liveTime.seekCount()).toBe(seeks);
370
+ unmountWithAct(root);
371
+ });
372
+
373
+ it("keeps the playhead's time when paused, played or reloaded over the frame", () => {
374
+ const { getApi, root } = renderTimelinePlayerHarness();
375
+ attachIframeWindow(getApi(), makeAdapterWindow().win);
376
+ seekWithAct(getApi(), 1.25);
377
+ previewFrame(3.4);
378
+ act(() => getApi().pause());
379
+ expect(usePlayerStore.getState().currentTime).toBe(1.25);
380
+ act(() => getApi().refreshPlayer());
381
+ const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
382
+ const shadow = makeFakeIframe(makeAdapterWindow().win);
383
+ shadow.src = "http://localhost/api/projects/demo/preview?_t=1";
384
+ act(() => {
385
+ getApi().setShadowIframeNode(shadow);
386
+ getApi().onShadowIframeLoad(gen);
387
+ getApi().onShadowReadyChange(gen, true);
388
+ });
389
+ expect(getApi().iframeRef.current).toBe(shadow);
390
+ expect(usePlayerStore.getState().currentTime).toBe(1.25);
391
+ previewFrame(3.5);
392
+ const live = (shadow.contentWindow as unknown as { __player: { getTime(): number } }).__player;
393
+ expect(live.getTime()).toBe(3.5);
394
+ act(() => getApi().play());
395
+ expect(live.getTime()).toBe(1.25);
396
+ unmountWithAct(root);
397
+ });
398
+
399
+ it("plays from the in-point when play rewinds from the end over the frame", () => {
400
+ const { api, root, adapter } = renderAttachedTimelinePlayer();
401
+ seekWithAct(api, 30);
402
+ act(() => usePlayerStore.setState({ inPoint: 3 }));
403
+ previewFrame(12);
404
+ act(() => api.play());
405
+ expect(adapter.getTime()).toBe(3);
406
+ unmountWithAct(root);
407
+ });
408
+
409
+ it("leaves live playback alone", () => {
410
+ const { api, root, adapter } = renderAttachedTimelinePlayer();
411
+ seekWithAct(api, 2);
412
+ setStorePlaying();
413
+ previewFrame(5);
414
+ expect(adapter.getTime()).toBe(2);
415
+ unmountWithAct(root);
416
+ });
417
+ });
418
+
353
419
  describe("useTimelinePlayer RAF loop wrap-around", () => {
354
420
  type SeekCall = { time: number; options?: { keepPlaying?: boolean } };
355
421
 
@@ -5,6 +5,8 @@ import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
5
5
  import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
6
6
  import { useShadowPreviewReload } from "./useShadowPreviewReload";
7
7
  import { resolvePlaybackAdapter } from "./playbackAdapterResolution";
8
+ import { subscribePreviewFrame, transportAdapter } from "../store/previewFrameStore";
9
+ import { clampToDuration } from "../lib/time";
8
10
  import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
9
11
  import { logReload } from "../../utils/reloadDebug";
10
12
 
@@ -148,10 +150,11 @@ export function useTimelinePlayer({
148
150
  const iframe = overrideIframe !== undefined ? overrideIframe : iframeRef.current;
149
151
  const win = iframe?.contentWindow as IframeWindow | null;
150
152
  if (!iframe || !win) return null;
151
- return resolvePlaybackAdapter(iframe, win, {
153
+ const adapter = resolvePlaybackAdapter(iframe, win, {
152
154
  cache: staticSeekAdapterRef,
153
155
  warned: staticSeekWarnedRef,
154
156
  });
157
+ return transportAdapter(adapter);
155
158
  } catch {
156
159
  return null;
157
160
  }
@@ -301,7 +304,7 @@ export function useTimelinePlayer({
301
304
  return false;
302
305
  }
303
306
  const duration = Math.max(0, adapter.getDuration());
304
- const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
307
+ const nextTime = clampToDuration(time, duration);
305
308
  const keepPlaying = options?.keepPlaying === true;
306
309
  const shouldResumeAfterSeek = shouldResumeForwardPlaybackAfterSeek({
307
310
  keepPlaying,
@@ -366,6 +369,7 @@ export function useTimelinePlayer({
366
369
  }
367
370
  });
368
371
  }, [seek, play, pause]);
372
+ useEffect(() => subscribePreviewFrame(getAdapter), [getAdapter]);
369
373
  const { playbackKeyDownRef, playbackKeyUpRef, attachIframeShortcutListeners, togglePlay } =
370
374
  usePlaybackKeyboard({
371
375
  iframeRef,
@@ -1,5 +1,8 @@
1
1
  export const STUDIO_PREVIEW_FPS = 30;
2
2
 
3
+ export const clampToDuration = (time: number, duration: number) =>
4
+ Math.max(0, duration > 0 ? Math.min(duration, time) : time);
5
+
3
6
  export function formatTime(time: number): string {
4
7
  if (!Number.isFinite(time) || time < 0) return "00:00";
5
8
  const mins = Math.floor(time / 60);
@@ -0,0 +1,48 @@
1
+ import { create } from "zustand";
2
+ import type { PlaybackAdapter } from "../lib/playbackTypes";
3
+ import { clampToDuration } from "../lib/time";
4
+ import { usePlayerStore } from "./playerStore";
5
+
6
+ /** A frame the preview shows without moving the playhead (a trim's dragged edge); null shows the playhead's. */
7
+ export const usePreviewFrameStore = create<{ time: number | null }>(() => ({ time: null }));
8
+
9
+ export const setPreviewFrame = (time: number | null) => usePreviewFrameStore.setState({ time });
10
+
11
+ // A trim's edge frame is on screen, not the transport's time: pause, play and a reload's
12
+ // hand-over read the playhead's time, and play starts from it unless the caller seeked first.
13
+ function showingPreviewFrame(adapter: PlaybackAdapter): PlaybackAdapter {
14
+ let seeked = false;
15
+ const time = () => (seeked ? adapter.getTime() : usePlayerStore.getState().currentTime);
16
+ return {
17
+ play: () => {
18
+ adapter.seek(time());
19
+ adapter.play();
20
+ },
21
+ pause: () => adapter.pause(),
22
+ seek: (t, options) => {
23
+ seeked = true;
24
+ adapter.seek(t, options);
25
+ },
26
+ getTime: time,
27
+ getDuration: () => adapter.getDuration(),
28
+ isPlaying: () => adapter.isPlaying(),
29
+ };
30
+ }
31
+
32
+ /** The adapter as the transport must see it while a paused preview frame is on screen. */
33
+ export function transportAdapter(adapter: PlaybackAdapter | null): PlaybackAdapter | null {
34
+ const showing = usePreviewFrameStore.getState().time !== null;
35
+ return adapter && showing && !usePlayerStore.getState().isPlaying
36
+ ? showingPreviewFrame(adapter)
37
+ : adapter;
38
+ }
39
+
40
+ /** Puts each preview frame on screen while paused, and the playhead's frame back when it clears. */
41
+ export function subscribePreviewFrame(getAdapter: () => PlaybackAdapter | null): () => void {
42
+ return usePreviewFrameStore.subscribe((state, prev) => {
43
+ if (state.time === prev.time || usePlayerStore.getState().isPlaying) return;
44
+ const adapter = getAdapter();
45
+ const time = state.time ?? usePlayerStore.getState().currentTime;
46
+ adapter?.seek(clampToDuration(time, adapter.getDuration()));
47
+ });
48
+ }