@hyperframes/studio 0.8.121 → 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-GkN9yzzq.js → hyperframes-player-DMzBHhbH.js} +1 -1
  2. package/dist/assets/{index-CyFqMHGS.js → index-B-YDp8hN.js} +174 -174
  3. package/dist/assets/{index-BpKhiuzT.js → index-CBRX2eZX.js} +1 -1
  4. package/dist/assets/{index-ByV72rWx.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
@@ -527,9 +527,52 @@ describe("planValueAtPlayhead", () => {
527
527
  ]);
528
528
  });
529
529
 
530
- it("refuses keyframes eased as a whole, whose segment curves it cannot keep", () => {
531
- const anim = { ...kf([{ percentage: 100, properties: { x: 20 } }]), ease: "power2.out" };
532
- expect(plan({ anim, values: { x: 5 } })).toEqual({ ok: false, reason: "eased-keyframes" });
530
+ describe("keyframes eased as a whole", () => {
531
+ const eased = {
532
+ ...kf([
533
+ { percentage: 0, properties: { x: 0 } },
534
+ { percentage: 100, properties: { x: 20 } },
535
+ ]),
536
+ ease: "power2.out",
537
+ };
538
+
539
+ it("changes the keyframe under the playhead and writes the ease back", () => {
540
+ const result = plan({ anim: eased, at: { time: 4 }, values: { x: 5 } });
541
+ expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([0, 5]);
542
+ expect(result.ok && result.mutation.ease).toBe("power2.out");
543
+ });
544
+
545
+ it("writes the keyframes' own ease as the tween's, as GSAP prefers it", () => {
546
+ const anim = { ...eased, keyframes: { ...eased.keyframes!, ease: "expo.in" } };
547
+ const result = plan({ anim, at: { time: 0 }, values: { x: 5 } });
548
+ expect(result.ok && result.mutation.ease).toBe("expo.in");
549
+ });
550
+
551
+ it("refuses an inner keyframe, which the ease shows at another time", () => {
552
+ const anim = {
553
+ ...kf([
554
+ { percentage: 0, properties: { x: 0 } },
555
+ { percentage: 50, properties: { x: 100 } },
556
+ { percentage: 100, properties: { x: 300 } },
557
+ ]),
558
+ ease: "power2.out",
559
+ };
560
+ expect(plan({ anim, at: { time: 2 }, values: { x: 5 } })).toEqual({
561
+ ok: false,
562
+ reason: "eased-keyframes",
563
+ });
564
+ });
565
+
566
+ it("refuses to add a keyframe, whose time the ease would move", () => {
567
+ expect(plan({ anim: eased, at: { time: 2 }, values: { x: 5 } })).toEqual({
568
+ ok: false,
569
+ reason: "eased-keyframes",
570
+ });
571
+ expect(plan({ anim: eased, at: { time: 6 }, values: { x: 5 } })).toEqual({
572
+ ok: false,
573
+ reason: "eased-keyframes",
574
+ });
575
+ });
533
576
  });
534
577
  });
535
578
 
@@ -55,6 +55,7 @@ export type PlayheadEditPlan =
55
55
  position: number;
56
56
  duration: number;
57
57
  keyframes: Keyframe[];
58
+ ease?: string;
58
59
  easeEach?: string;
59
60
  };
60
61
  /** A keyframe was added, not only changed: keyframe usage counts it as an add. */
@@ -70,6 +71,7 @@ const isLinear = (ease: string | undefined) => !ease || ease === "none" || ease
70
71
 
71
72
  interface Normalized {
72
73
  keyframes: Keyframe[];
74
+ runEase?: string;
73
75
  easeEach?: string;
74
76
  }
75
77
 
@@ -82,10 +84,10 @@ function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefus
82
84
  const { anim } = edit;
83
85
  const data = anim.keyframes;
84
86
  if (data) {
85
- if (!isLinear(anim.ease) || !isLinear(data.ease)) return { reason: "eased-keyframes" };
86
87
  if (data.format === "simple-array") return { reason: "simple-array-keyframes" };
87
88
  const arrayStep = data.format === "object-array";
88
89
  return {
90
+ runEase: data.ease ?? anim.ease,
89
91
  keyframes: data.keyframes.map((kf) => ({
90
92
  ...kf,
91
93
  properties: { ...kf.properties },
@@ -126,8 +128,11 @@ function valueAt(keyframes: Keyframe[], prop: string, side: "first" | "last") {
126
128
  return kf ? { percentage: kf.percentage, value: kf.properties[prop]! } : null;
127
129
  }
128
130
 
131
+ const keyframeAt = (keyframes: Keyframe[], percentage: number) =>
132
+ keyframes.find((kf) => Math.abs(kf.percentage - percentage) <= KEYFRAME_PCT_MATCH);
133
+
129
134
  function upsert(keyframes: Keyframe[], percentage: number, properties: Props, ease?: string) {
130
- const hit = keyframes.find((kf) => Math.abs(kf.percentage - percentage) <= KEYFRAME_PCT_MATCH);
135
+ const hit = keyframeAt(keyframes, percentage);
131
136
  if (hit) {
132
137
  Object.assign(hit.properties, properties);
133
138
  return hit;
@@ -195,14 +200,18 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
195
200
  "percentage" in edit.at
196
201
  ? edit.at.percentage
197
202
  : roundPct(((edit.at.time - start) / duration) * 100);
203
+ const eased = !isLinear(norm.runEase);
198
204
  if (pct >= -KEYFRAME_PCT_MATCH && pct <= 100 + KEYFRAME_PCT_MATCH) {
199
205
  const at = Math.min(100, Math.max(0, pct));
206
+ const hit = keyframeAt(keyframes, at);
207
+ if (eased && hit?.percentage !== 0 && hit?.percentage !== 100) return refuse("eased-keyframes");
200
208
  const next = [...keyframes]
201
209
  .sort((a, b) => a.percentage - b.percentage)
202
210
  .find((kf) => kf.percentage > at + KEYFRAME_PCT_MATCH);
203
211
  // The new keyframe splits next's segment, so it takes next's ease.
204
212
  upsert(keyframes, at, { ...values }, next?.ease);
205
213
  } else {
214
+ if (eased) return refuse("eased-keyframes");
206
215
  const time = (edit.at as { time: number }).time;
207
216
  const before = time < start;
208
217
  const held = heldEnds(norm, before ? "start" : "end", edit.implicitEndValue, backfilled);
@@ -232,6 +241,7 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
232
241
  position: roundTo3(position),
233
242
  duration: roundTo3(span),
234
243
  keyframes,
244
+ ...(norm.runEase ? { ease: norm.runEase } : {}),
235
245
  ...(norm.easeEach ? { easeEach: norm.easeEach } : {}),
236
246
  },
237
247
  added: keyframes.length > authored,
@@ -61,6 +61,22 @@ describe("dispatchLinkShortcut", () => {
61
61
  expect(handleTimelineElementDeleteOnly).toHaveBeenCalledWith(video);
62
62
  });
63
63
 
64
+ it("leaves ⌥⌫ to a text field the user is typing in", () => {
65
+ const video = clip("talk", "video", { muted: true, link: "lk-1" });
66
+ select([video, clip("talk-audio", "audio", { link: "lk-1" })], "talk");
67
+ const input = document.createElement("input");
68
+ document.body.append(input);
69
+ const event = key({ altKey: true, key: "Backspace", bubbles: true });
70
+ const handleTimelineElementDeleteOnly = vi.fn();
71
+ input.addEventListener("keydown", (e) =>
72
+ dispatchLinkShortcut(e, { handleLinkEdit: vi.fn(), handleTimelineElementDeleteOnly }),
73
+ );
74
+ input.dispatchEvent(event);
75
+ input.remove();
76
+ expect(handleTimelineElementDeleteOnly).not.toHaveBeenCalled();
77
+ expect(event.preventDefault).not.toHaveBeenCalled();
78
+ });
79
+
64
80
  it("leaves the key alone when no link item applies", () => {
65
81
  select([clip("title", "div")], "title");
66
82
  const event = key({ metaKey: true, key: "l" });
@@ -1,6 +1,7 @@
1
1
  import { usePlayerStore } from "../player";
2
2
  import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
3
3
  import { resolveLinkMenuItems } from "../player/components/clipMenuLinkItems";
4
+ import { isTypingTarget } from "../utils/typingTarget";
4
5
 
5
6
  export interface LinkShortcutCallbacks {
6
7
  handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
@@ -22,6 +23,7 @@ function shortcutOf(event: KeyboardEvent): string | null {
22
23
 
23
24
  /** ⌘L unlink/link, ⌥⇧D detach audio, ⌥⌫ delete one linked clip — the clip menu's shortcuts. */
24
25
  export function dispatchLinkShortcut(event: KeyboardEvent, cb: LinkShortcutCallbacks): boolean {
26
+ if (isTypingTarget(event.target)) return false;
25
27
  const shortcut = shortcutOf(event);
26
28
  if (!shortcut) return false;
27
29
  const { elements, selectedElementId, selectedElementIds } = usePlayerStore.getState();
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react";
2
2
  import { usePlayerStore } from "../player";
3
3
  import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
- import { isTypingTarget } from "../utils/typingTarget";
6
5
  import { dispatchLinkShortcut, type LinkShortcutCallbacks } from "./linkShortcuts";
7
6
  import { useCaptionStore } from "../captions/store";
8
7
  import {
@@ -226,7 +225,7 @@ export function useAppHotkeys({
226
225
  const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
227
226
  const cb = cbRef.current;
228
227
  const key = event.key.toLowerCase();
229
- if (!isTypingTarget(event.target) && dispatchLinkShortcut(event, cb)) return;
228
+ if (dispatchLinkShortcut(event, cb)) return;
230
229
  if (event.metaKey || event.ctrlKey) {
231
230
  dispatchModifierKey(event, key, cb);
232
231
  return;
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export type { EditorShellProps } from "./components/EditorShell";
7
7
  export { NLEPreview } from "./components/nle/NLEPreview";
8
8
  export { DEFAULT_SHORTCUT_SECTIONS } from "./player/components/studioShortcuts";
9
9
  export { ShortcutsButton } from "./player/components/ShortcutsPanel";
10
+ export { dispatchLinkShortcut } from "./hooks/linkShortcuts";
11
+ export type { LinkShortcutCallbacks } from "./hooks/linkShortcuts";
10
12
  export type { ShortcutsButtonProps } from "./player/components/ShortcutsPanel";
11
13
  export type { ShortcutHint, ShortcutSection } from "./player/components/studioShortcuts";
12
14
  export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
@@ -48,6 +48,7 @@ const SNAP_LABEL: Record<TimelineSnapType, string> = {
48
48
  playhead: "playhead",
49
49
  "clip-edge": "clip edge",
50
50
  beat: "beat",
51
+ grid: "grid line",
51
52
  };
52
53
 
53
54
  export type ClipFadeShape = AudioFades & { duration: number };
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
3
3
  import {
4
4
  computeDragPreview,
5
5
  computeResizePreview,
6
+ guideIfSaved,
6
7
  getTimelineDragOverlayPosition,
7
8
  type DragPreviewContext,
8
9
  } from "./timelineClipDragPreview";
@@ -293,6 +294,52 @@ describe("computeDragPreview — a clip landing on an empty main track keeps its
293
294
  });
294
295
  });
295
296
 
297
+ describe("guideIfSaved — a guide only where the clip saves", () => {
298
+ const grid = { time: 3.25, type: "grid" as const };
299
+
300
+ it("drops the guide when start and duration round apart from the target", () => {
301
+ // 1440 px/s: 1.125s + 2.125s saves as 1.13s + 2.13s, 14px past the 3.25s guide.
302
+ const el = clip("a", 0, 1.125, 2, 0, "div");
303
+ expect(guideIfSaved(el, { start: 1.125, duration: 2.125 }, grid, 1440)).toBeNull();
304
+ });
305
+
306
+ it("drops the guide when a nested clip's local start rounds off it", () => {
307
+ const el = { ...clip("a", 0, 2, 1, 0, "div"), parentCompositionStart: 1 / 30 };
308
+ const target = { time: 2.1, type: "playhead" as const };
309
+ expect(guideIfSaved(el, { start: 2.1, duration: 1 }, target, 1440)).toBeNull();
310
+ });
311
+
312
+ it("keeps the guide when the saved edge is on it", () => {
313
+ const el = clip("a", 0, 1, 2, 0, "div");
314
+ expect(guideIfSaved(el, { start: 1, duration: 2.25 }, grid, 1440)).toBe(grid);
315
+ });
316
+
317
+ it("judges a move by the duration it keeps, not a rounded one", () => {
318
+ // A 31/30 s clip moved so its end meets 3.25s saves its start at 2.22s and ends 4.8px past.
319
+ const el = clip("a", 0, 1, 31 / 30, 0, "div");
320
+ const edge = { time: 3.25, type: "clip-edge" as const };
321
+ expect(guideIfSaved(el, { start: 2.217 }, edge, 1440)).toBeNull();
322
+ });
323
+
324
+ it("draws no guide for a move whose saved start misses the target", () => {
325
+ const nested = { ...moodboard, parentCompositionStart: 1 / 30 };
326
+ const { drag } = horizontalDrag(nested, 0.5, 0);
327
+ const context = {
328
+ ...ctx(),
329
+ pps: 1440,
330
+ buildSnapTargets: () => [{ time: 21, type: "playhead" as const }],
331
+ };
332
+ const next = computeDragPreview(
333
+ drag,
334
+ drag.originClientX + 2 * 1440,
335
+ drag.originClientY,
336
+ context,
337
+ );
338
+ expect(next.previewStart).toBe(21);
339
+ expect(next.snapTime).toBeNull();
340
+ });
341
+ });
342
+
296
343
  describe("computeResizePreview — composition source continuity", () => {
297
344
  it("seeds a legacy composition offset and advances it at playback rate", () => {
298
345
  const element = {
@@ -320,6 +367,27 @@ describe("computeResizePreview — composition source continuity", () => {
320
367
  });
321
368
  });
322
369
 
370
+ it("draws no guide when the trimmed start cannot save onto the target", () => {
371
+ // 1440 px/s: a playhead on frame 31 (1.033s) saves a start of 1.03s, 4px off the guide.
372
+ const result = computeResizePreview(
373
+ {
374
+ element: clip("a", 0, 1, 2, 0, "div"),
375
+ edge: "start",
376
+ originClientX: 0,
377
+ previewStart: 1,
378
+ previewDuration: 2,
379
+ started: true,
380
+ },
381
+ 48,
382
+ {
383
+ scroll: fakeScroll(),
384
+ pps: 1440,
385
+ buildSnapTargets: () => [{ time: 1.033, type: "playhead" }],
386
+ },
387
+ );
388
+ expect(result).toMatchObject({ previewStart: 1.03, snapTime: null, snapType: null });
389
+ });
390
+
323
391
  it("does not let a tail snap shrink a clip below the drag's minimum duration", () => {
324
392
  const result = computeResizePreview(
325
393
  {
@@ -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
  });