@hyperframes/studio 0.8.121 → 0.8.123

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 (63) hide show
  1. package/dist/assets/{hyperframes-player-GkN9yzzq.js → hyperframes-player-yABdb6yk.js} +1 -1
  2. package/dist/assets/{index-ByV72rWx.js → index-D77MIjSJ.js} +1 -1
  3. package/dist/assets/{index-BpKhiuzT.js → index-DWjF2Kxw.js} +1 -1
  4. package/dist/assets/{index-CyFqMHGS.js → index-Dt7lWtY7.js} +184 -184
  5. package/dist/assets/{index-t1LH6H5H.css → index-LrNlT5Tg.css} +1 -1
  6. package/dist/{chunk-J6Q572FA.js → chunk-246VB2G3.js} +3 -1
  7. package/dist/chunk-246VB2G3.js.map +1 -0
  8. package/dist/index.d.ts +10 -2
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +1611 -1310
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +8 -8
  15. package/src/components/FrameyGlyph.tsx +52 -0
  16. package/src/components/OpenInDesktopButton.dom.test.tsx +113 -0
  17. package/src/components/OpenInDesktopButton.tsx +153 -0
  18. package/src/components/StudioHeader.dom.test.tsx +2 -0
  19. package/src/components/StudioHeader.tsx +2 -0
  20. package/src/components/TimelineToolbar.test.tsx +25 -0
  21. package/src/components/TimelineToolbar.tsx +5 -1
  22. package/src/components/editor/MotionPathNode.tsx +2 -1
  23. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +99 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +6 -0
  25. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +57 -0
  26. package/src/components/editor/domEditOverlayStartGesture.ts +1 -1
  27. package/src/components/editor/motionPathGeometry.test.ts +10 -0
  28. package/src/components/editor/motionPathGeometry.ts +8 -5
  29. package/src/components/editor/motionPathLayerNode.test.ts +82 -0
  30. package/src/components/editor/motionPathLayerNode.ts +28 -0
  31. package/src/components/editor/useDomEditNudge.test.tsx +53 -1
  32. package/src/components/editor/useDomEditNudge.ts +2 -2
  33. package/src/components/editor/useMotionPathData.test.tsx +30 -0
  34. package/src/components/editor/useMotionPathData.ts +3 -1
  35. package/src/hooks/gsapValueAtPlayhead.test.ts +46 -3
  36. package/src/hooks/gsapValueAtPlayhead.ts +12 -2
  37. package/src/hooks/linkShortcuts.test.ts +16 -0
  38. package/src/hooks/linkShortcuts.ts +2 -0
  39. package/src/hooks/useAppHotkeys.ts +1 -2
  40. package/src/index.ts +2 -0
  41. package/src/player/components/TimelineClipFades.tsx +1 -0
  42. package/src/player/components/timelineClipDragPreview.test.ts +68 -0
  43. package/src/player/components/timelineClipDragPreview.ts +39 -14
  44. package/src/player/components/timelineLayout.test.ts +24 -1
  45. package/src/player/components/timelineRulerGeometry.ts +24 -3
  46. package/src/player/components/timelineSnapping.test.ts +43 -12
  47. package/src/player/components/timelineSnapping.ts +33 -15
  48. package/src/player/components/useTimelineClipDrag.resize.test.tsx +54 -54
  49. package/src/player/components/useTimelineClipDrag.ts +17 -29
  50. package/src/player/components/useTimelineProviderState.tsx +0 -1
  51. package/src/player/components/useTimelineTicks.ts +6 -3
  52. package/src/player/hooks/timelinePlayerTestHarness.ts +2 -0
  53. package/src/player/hooks/useTimelinePlayer.seek.test.ts +66 -0
  54. package/src/player/hooks/useTimelinePlayer.ts +6 -2
  55. package/src/player/lib/time.ts +3 -0
  56. package/src/player/store/previewFrameStore.ts +48 -0
  57. package/src/player/store/timelineElement.ts +12 -0
  58. package/src/styles/components.css +51 -0
  59. package/src/styles/theme.css +3 -0
  60. package/src/timelineChromeExports.test.tsx +6 -0
  61. package/src/utils/gsapSoftReload.test.ts +50 -0
  62. package/src/utils/gsapSoftReload.ts +22 -2
  63. package/dist/chunk-J6Q572FA.js.map +0 -1
@@ -5,7 +5,7 @@ import {
5
5
  resolveTimelineMinDuration,
6
6
  } from "./timelineGroupEditing";
7
7
  import type { TimelineElement } from "../store/playerStore";
8
- import { clampToHostStart } from "../store/timelineElement";
8
+ import { clampToHostStart, savedClipEdges } from "../store/timelineElement";
9
9
  import {
10
10
  CLIP_Y,
11
11
  TRACK_H,
@@ -34,7 +34,6 @@ import { heldAudioShiftRange, heldPartnerVideoBounds } from "./audioClipLink";
34
34
  type BuildSnapTargets = (
35
35
  excludeElementKey: string | null,
36
36
  includeBeats: boolean,
37
- includePlayhead?: boolean,
38
37
  ) => TimelineSnapTarget[];
39
38
 
40
39
  export interface DragPreviewContext {
@@ -56,6 +55,8 @@ export interface DragPreviewContext {
56
55
  * on demand from `elements`, so the result is identical either way.
57
56
  */
58
57
  audioTracks?: ReadonlySet<number>;
58
+ /** Seconds between the ruler's lines; 0 or absent when snapping is off. */
59
+ gridStep?: number;
59
60
  }
60
61
 
61
62
  export function createKeyboardClipDrag(
@@ -248,6 +249,7 @@ export function computeDragPreview(
248
249
  // Relaxed clamp: allow the snapped start past the content, up to the
249
250
  // rendered extent (see dragMaxStart) — the composition grows on commit.
250
251
  dragMaxStart + drag.element.duration,
252
+ ctx.gridStep,
251
253
  );
252
254
  // A group moves rigidly: the grabbed clip stops where any mover would cross its host's start.
253
255
  const group = resolveGroupDrag(drag, ctx);
@@ -266,6 +268,11 @@ export function computeDragPreview(
266
268
  return { ...drag, started: true };
267
269
  }
268
270
  const { track: previewTrack, insertRow } = placement;
271
+ const target =
272
+ placement.start === snap.start && snap.snapTime !== null && snap.snapType !== null
273
+ ? { time: snap.snapTime, type: snap.snapType }
274
+ : null;
275
+ const guide = guideIfSaved(drag.element, { start: snap.start }, target, pps);
269
276
  return {
270
277
  ...drag,
271
278
  started: true,
@@ -277,11 +284,24 @@ export function computeDragPreview(
277
284
  // tell a deliberate vertical lane change from a horizontal drag.
278
285
  desiredTrack: nextMove.track,
279
286
  insertRow,
280
- snapTime: placement.start === snap.start ? snap.snapTime : null,
281
- snapType: placement.start === snap.start ? snap.snapType : null,
287
+ snapTime: guide?.time ?? null,
288
+ snapType: guide?.type ?? null,
282
289
  };
283
290
  }
284
291
 
292
+ /** A snap's guide, kept only when the clip's edge as its file will save it is within a pixel of it. */
293
+ export function guideIfSaved(
294
+ element: TimelineElement,
295
+ clip: { start: number; duration?: number },
296
+ target: TimelineSnapTarget | null,
297
+ pps: number,
298
+ ): TimelineSnapTarget | null {
299
+ if (!target) return null;
300
+ const saved = savedClipEdges(element, clip.start, clip.duration);
301
+ const off = Math.min(Math.abs(saved.start - target.time), Math.abs(saved.end - target.time));
302
+ return off * pps < 1 ? target : null;
303
+ }
304
+
285
305
  /** One frame: the last visible frame of a clip sits just before its end time. */
286
306
  const TRIM_END_FRAME_LEAD_S = 1 / STUDIO_PREVIEW_FPS;
287
307
 
@@ -296,6 +316,8 @@ export interface ResizePreviewContext {
296
316
  buildSnapTargets: BuildSnapTargets;
297
317
  elements?: readonly TimelineElement[];
298
318
  gestureKeys?: ReadonlySet<string>;
319
+ /** Seconds between the ruler's lines; 0 or absent when snapping is off. */
320
+ gridStep?: number;
299
321
  }
300
322
 
301
323
  export interface ResizePreviewResult {
@@ -362,21 +384,19 @@ export function computeResizePreview(
362
384
  effectiveClientX,
363
385
  );
364
386
 
365
- // Snap to beats and clip edges, never the playhead (the dragged edge drives
366
- // its own preview seek, so that would be circular). Stay inside the same
367
- // limits resolveTimelineResize enforces. The music track defines the
368
- // beats, so it must not snap to them, but still snaps to clip edges.
387
+ // Snap within the same limits resolveTimelineResize enforces. The music
388
+ // track defines the beats, so it must not snap to them.
369
389
  const trimTargets = buildSnapTargets(
370
390
  resize.element.key ?? resize.element.id,
371
391
  !isMusicTrack(resize.element),
372
- false,
373
392
  );
393
+ const gridStep = ctx.gridStep ?? 0;
374
394
  let snap: TimelineSnapTarget | null = null;
375
- if (trimTargets.length > 0) {
395
+ if (trimTargets.length > 0 || gridStep > 0) {
376
396
  const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1);
377
397
  if (resize.edge === "end") {
378
398
  const edgeTime = nextResize.start + nextResize.duration;
379
- const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs);
399
+ const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs, gridStep);
380
400
  // Stay within [start+minDuration, maxEnd] so the snap can't create a
381
401
  // degenerate clip or run past the source/composition limit.
382
402
  const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
@@ -387,16 +407,21 @@ export function computeResizePreview(
387
407
  ) {
388
408
  // An edge already on the target still owns the guide; only move it when off.
389
409
  if (snapped !== edgeTime) nextResize = { ...nextResize, duration: snappedDuration };
390
- snap = target;
410
+ snap = guideIfSaved(resize.element, nextResize, target, pps);
391
411
  }
392
412
  } else {
393
- const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
413
+ const { time: snapped, target } = snapTimelineTime(
414
+ nextResize.start,
415
+ trimTargets,
416
+ snapSecs,
417
+ gridStep,
418
+ );
394
419
  const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
395
420
  const delta = snapped - nextResize.start;
396
421
  const bounds = clipStartTrimDeltaBounds(clip, minStart, resolveTimelineMinDuration());
397
422
  if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
398
423
  if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
399
- snap = target;
424
+ snap = guideIfSaved(resize.element, nextResize, target, pps);
400
425
  }
401
426
  }
402
427
  }
@@ -18,7 +18,11 @@ import {
18
18
  resolveTimelineAssetDrop,
19
19
  getTimelineBeatEntries,
20
20
  } from "./timelineLayout";
21
- import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
21
+ import {
22
+ generateTicks,
23
+ getTimelineGridStep,
24
+ getTimelineMajorTickInterval,
25
+ } from "./timelineRulerGeometry";
22
26
  import { resolveInsertRow } from "./timelineCollision";
23
27
  import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
24
28
 
@@ -42,6 +46,25 @@ describe("horizontal timeline window", () => {
42
46
  expect(ticks.major.length + ticks.minor.length).toBeLessThan(100);
43
47
  });
44
48
 
49
+ it("spaces the snap grid exactly like the ruler's drawn lines at each zoom", () => {
50
+ for (const [pps, fps] of [
51
+ [100, undefined],
52
+ [12, undefined],
53
+ [900, undefined],
54
+ [100, 30],
55
+ [3, 30],
56
+ ] as const) {
57
+ const { major, minor } = generateTicks(60, pps, fps);
58
+ const lines = [...major, ...minor].sort((a, b) => a - b);
59
+ expect(getTimelineGridStep(60, pps, fps)).toBeCloseTo(lines[1]! - lines[0]!, 6);
60
+ }
61
+ });
62
+
63
+ it("has no snap grid where the ruler draws no lines", () => {
64
+ expect(generateTicks(20_000, 100)).toEqual({ major: [], minor: [] });
65
+ expect(getTimelineGridStep(20_000, 100)).toBe(0);
66
+ });
67
+
45
68
  it("slices beat records with original strength indexes and unions a pinned beat", () => {
46
69
  expect(
47
70
  getTimelineBeatEntries(
@@ -1,4 +1,5 @@
1
- import { formatTime } from "../lib/time";
1
+ import { formatTime, STUDIO_PREVIEW_FPS } from "../lib/time";
2
+ import type { TimelineTimeDisplayMode } from "../../utils/studioUiPreferences";
2
3
  import type { TimelineTimeRange } from "../lib/timelineClipIndex";
3
4
 
4
5
  // fallow-ignore-next-line complexity
@@ -87,6 +88,27 @@ function appendMinorTicks(
87
88
  }
88
89
  }
89
90
 
91
+ function tickSpacing(duration: number, pixelsPerSecond?: number, frameRate?: number) {
92
+ const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate);
93
+ const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
94
+ return { majorInterval, subdivisions };
95
+ }
96
+
97
+ export function rulerFrameRate(mode: TimelineTimeDisplayMode): number | undefined {
98
+ return mode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
99
+ }
100
+
101
+ /** Seconds between neighbouring ruler lines (minor ticks when drawn, else major): the snap grid. */
102
+ export function getTimelineGridStep(
103
+ duration: number,
104
+ pixelsPerSecond?: number,
105
+ frameRate?: number,
106
+ ): number {
107
+ if (!isSupportedTickDuration(duration)) return 0;
108
+ const { majorInterval, subdivisions } = tickSpacing(duration, pixelsPerSecond, frameRate);
109
+ return subdivisions > 0 ? majorInterval / subdivisions : majorInterval;
110
+ }
111
+
90
112
  export function generateTicks(
91
113
  duration: number,
92
114
  pixelsPerSecond?: number,
@@ -94,8 +116,7 @@ export function generateTicks(
94
116
  range?: TimelineTimeRange,
95
117
  ): { major: number[]; minor: number[] } {
96
118
  if (!isSupportedTickDuration(duration)) return { major: [], minor: [] };
97
- const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate);
98
- const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
119
+ const { majorInterval, subdivisions } = tickSpacing(duration, pixelsPerSecond, frameRate);
99
120
  const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
100
121
  const major: number[] = [];
101
122
  const minor: number[] = [];
@@ -27,18 +27,6 @@ describe("collectTimelineSnapTargets", () => {
27
27
  expect(targets).toContainEqual({ time: 0.5, type: "beat" });
28
28
  });
29
29
 
30
- it("omits the playhead when includePlayhead is false, for a trim", () => {
31
- const targets = collectTimelineSnapTargets({
32
- elements,
33
- playheadTime: 7.25,
34
- beatTimes: [0.5],
35
- includePlayhead: false,
36
- });
37
- expect(targets.some((t) => t.type === "playhead")).toBe(false);
38
- expect(targets).toContainEqual({ time: 2, type: "clip-edge" });
39
- expect(targets).toContainEqual({ time: 0.5, type: "beat" });
40
- });
41
-
42
30
  it("excludes the dragged element's own edges", () => {
43
31
  const targets = collectTimelineSnapTargets({
44
32
  elements,
@@ -90,6 +78,35 @@ describe("snapTimelineTime", () => {
90
78
  it("returns input unchanged when nothing is within threshold", () => {
91
79
  expect(snapTimelineTime(6, targets, 0.1)).toEqual({ time: 6, target: null });
92
80
  });
81
+
82
+ it("snaps to the nearest ruler line when no target is in range", () => {
83
+ expect(snapTimelineTime(6.27, targets, 0.08, 0.25)).toEqual({
84
+ time: 6.25,
85
+ target: { time: 6.25, type: "grid" },
86
+ });
87
+ expect(snapTimelineTime(6.37, targets, 0.08, 0.25)).toEqual({ time: 6.37, target: null });
88
+ });
89
+
90
+ it("prefers a target in range over a nearer ruler line", () => {
91
+ expect(snapTimelineTime(5.24, targets, 0.08, 0.25).target).toEqual({
92
+ time: 5.3,
93
+ type: "playhead",
94
+ });
95
+ });
96
+
97
+ it("snaps a line at its saved centisecond, and not at all when that is a pixel or more off", () => {
98
+ // 187.5 px/s: 4.625s saves as 4.63s, under a pixel away, so the guide sits where the clip lands.
99
+ expect(snapTimelineTime(4.627, [], 8 / 187.5, 0.125).target).toEqual({
100
+ time: 4.63,
101
+ type: "grid",
102
+ });
103
+ // 1440 px/s: 1.025s would save 7px off its line, so the line does not snap.
104
+ expect(snapTimelineTime(1.026, [], 8 / 1440, 0.025)).toEqual({ time: 1.026, target: null });
105
+ });
106
+
107
+ it("lands on whole frames for a frame-spaced grid", () => {
108
+ expect(snapTimelineTime(1.01, [], 0.08, 1 / 30).time).toBe(1);
109
+ });
93
110
  });
94
111
 
95
112
  describe("snapMoveToTargets", () => {
@@ -120,6 +137,20 @@ describe("snapMoveToTargets", () => {
120
137
  expect(snapMoveToTargets(5.5, 2, targets, 1000, 60).snapTime).toBeNull();
121
138
  });
122
139
 
140
+ it("snaps a clip edge to the ruler grid when no target is near", () => {
141
+ expect(snapMoveToTargets(1.02, 2, [], 100, 60, 0.25)).toEqual({
142
+ start: 1,
143
+ snapTime: 1,
144
+ snapType: "grid",
145
+ });
146
+ });
147
+
148
+ it("prefers the edge on a real target over the other edge nearer a ruler line", () => {
149
+ // start 2.99 is 1px from the 3s line; the end 4.94 is 6px from the playhead at 5
150
+ const r = snapMoveToTargets(2.99, 1.95, targets, 100, 60, 0.25);
151
+ expect(r).toEqual({ start: 3.05, snapTime: 5, snapType: "playhead" });
152
+ });
153
+
123
154
  it("TIMELINE_SNAP_PX matches the historical beat-snap threshold", () => {
124
155
  expect(TIMELINE_SNAP_PX).toBe(8);
125
156
  });
@@ -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
  });