@hyperframes/studio 0.8.89 → 0.8.91

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 (93) hide show
  1. package/dist/assets/{hyperframes-player-CVoS7kv8.js → hyperframes-player-zT2Bdx_l.js} +1 -1
  2. package/dist/assets/{index-D35-L_yn.js → index-DSOLcE8X.js} +1 -1
  3. package/dist/assets/{index-DBub9qQY.js → index-DV-z19zB.js} +1 -1
  4. package/dist/assets/{index-BM8PwYby.js → index-V-0rfhe6.js} +168 -168
  5. package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
  6. package/dist/chunk-QPSOTK6M.js.map +1 -0
  7. package/dist/index.d.ts +12 -3
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1349 -1066
  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 +12 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +5 -1
  15. package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
  16. package/src/components/editor/elementPositionOffset.test.ts +62 -0
  17. package/src/components/editor/elementPositionOffset.ts +64 -0
  18. package/src/components/editor/motionPathCommit.test.ts +25 -5
  19. package/src/components/editor/motionPathSelection.ts +10 -3
  20. package/src/components/editor/useInlineTextEditing.tsx +2 -4
  21. package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
  22. package/src/components/editor/useLayerRevealOverride.ts +2 -1
  23. package/src/components/nle/NLEContext.tsx +1 -1
  24. package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
  25. package/src/hooks/domEditPersistFailure.ts +8 -6
  26. package/src/hooks/elementOffsetStager.ts +65 -0
  27. package/src/hooks/gestureTransaction.ts +4 -2
  28. package/src/hooks/gsapDragCommit.ts +1 -0
  29. package/src/hooks/gsapEditOutcome.ts +2 -1
  30. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
  31. package/src/hooks/gsapRuntimeBridge.ts +50 -14
  32. package/src/hooks/gsapShared.test.ts +1 -1
  33. package/src/hooks/gsapShared.ts +16 -12
  34. package/src/hooks/gsapTweenReach.ts +30 -0
  35. package/src/hooks/timelineEditingHelpers.ts +2 -1
  36. package/src/hooks/useClipboard.source.test.ts +58 -0
  37. package/src/hooks/useClipboard.ts +12 -3
  38. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  39. package/src/hooks/useDomEditCommits.ts +2 -0
  40. package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
  41. package/src/hooks/useDomEditSession.ts +2 -0
  42. package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
  43. package/src/hooks/useDomGeometryCommit.ts +42 -1
  44. package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
  45. package/src/hooks/useDomGeometryCommits.ts +14 -1
  46. package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
  47. package/src/hooks/useGsapAwareEditing.ts +44 -7
  48. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
  49. package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
  50. package/src/hooks/useRazorSplit.test.ts +5 -5
  51. package/src/hooks/useTimelineEditing.test.tsx +384 -0
  52. package/src/hooks/useTimelineEditing.ts +12 -12
  53. package/src/hooks/useTimelineGroupEditing.ts +16 -7
  54. package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
  55. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  56. package/src/player/components/TimelineProvider.tsx +1 -0
  57. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  58. package/src/player/components/TimelineTypes.ts +1 -1
  59. package/src/player/components/timelineClipDragCommit.test.ts +18 -8
  60. package/src/player/components/timelineClipDragCommit.ts +5 -0
  61. package/src/player/components/timelineClipDragPreview.test.ts +70 -2
  62. package/src/player/components/timelineClipDragPreview.ts +15 -12
  63. package/src/player/components/timelineEditing.ts +2 -1
  64. package/src/player/components/timelineGroupEditing.ts +7 -2
  65. package/src/player/components/timelineMultiDragPreview.test.ts +10 -8
  66. package/src/player/components/timelineMultiDragPreview.ts +8 -5
  67. package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
  68. package/src/player/components/timelineProviderStateBuilders.ts +7 -2
  69. package/src/player/components/timelineZMirror.test.ts +2 -2
  70. package/src/player/components/timelineZMirror.ts +1 -1
  71. package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
  72. package/src/player/components/useTimelineClipDrag.ts +3 -2
  73. package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
  74. package/src/player/components/useTimelinePlayhead.ts +56 -27
  75. package/src/player/components/useTimelineProviderState.tsx +1 -0
  76. package/src/player/components/useTimelineZoom.ts +9 -1
  77. package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
  78. package/src/player/hooks/useTimelinePlayer.ts +7 -4
  79. package/src/player/lib/timelineDOM.test.ts +79 -0
  80. package/src/player/lib/timelineDOM.ts +16 -7
  81. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  82. package/src/player/lib/timelineIframeHelpers.ts +12 -3
  83. package/src/player/sceneSwap.ts +21 -0
  84. package/src/player/store/liveTime.ts +6 -0
  85. package/src/player/store/playerStore.ts +2 -0
  86. package/src/player/store/timelineElement.ts +28 -3
  87. package/src/utils/gsapUndoRestore.test.ts +58 -0
  88. package/src/utils/gsapUndoRestore.ts +6 -1
  89. package/src/utils/razorSplitTransaction.test.ts +1 -1
  90. package/src/utils/razorSplitTransaction.ts +3 -3
  91. package/src/utils/studioPreviewHelpers.test.ts +29 -0
  92. package/src/utils/studioPreviewHelpers.ts +37 -80
  93. package/dist/chunk-KU4F7QZ3.js.map +0 -1
@@ -15,7 +15,7 @@ export interface TimelineProps
15
15
  extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps {
16
16
  /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
17
17
  sessionEpoch?: number;
18
- onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
18
+ onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
19
19
  onDrillDown?: (element: TimelineElement) => void;
20
20
  /** Picture only: takes no pointer input. Interactive content goes in renderClipOverlay. */
21
21
  renderClipContent?: (
@@ -315,7 +315,6 @@ describe("commitDraggedClipMove", () => {
315
315
  const child = {
316
316
  ...el("scene.html#title", 0.25, 12, 2),
317
317
  sourceFile: "scene.html",
318
- expandedParentStart: 10,
319
318
  expandedHostKey: "host",
320
319
  };
321
320
  const { updateElement, onMoveElement, onMoveElements } = runClipMove(
@@ -338,7 +337,6 @@ describe("commitDraggedClipMove", () => {
338
337
  const child = {
339
338
  ...el("scene.html#title", 0.25, 12, 2),
340
339
  sourceFile: "scene.html",
341
- expandedParentStart: 10,
342
340
  expandedHostKey: "host",
343
341
  };
344
342
  const { onMoveElement, onMoveElements } = runClipMove(
@@ -360,7 +358,6 @@ describe("commitDraggedClipMove", () => {
360
358
  const child = {
361
359
  ...el("scene.html#title", 0.25, 12, 2),
362
360
  sourceFile: "scene.html",
363
- expandedParentStart: 10,
364
361
  expandedHostKey: "host",
365
362
  };
366
363
  const { onMoveElement, onMoveElements } = runClipMove(
@@ -382,7 +379,6 @@ describe("commitDraggedClipMove", () => {
382
379
  const child = {
383
380
  ...el("scene.html#title", 0.25, 12, 2),
384
381
  sourceFile: "scene.html",
385
- expandedParentStart: 10,
386
382
  expandedHostKey: "host",
387
383
  };
388
384
  const { onMoveElement, onMoveElements } = runClipMove(
@@ -406,7 +402,6 @@ describe("commitDraggedClipMove", () => {
406
402
  const child = {
407
403
  ...el("scene.html#title", 0.25, 12, 2),
408
404
  sourceFile: "scene.html",
409
- expandedParentStart: 10,
410
405
  expandedHostKey: "host",
411
406
  };
412
407
  const { onMoveElement, onMoveElements } = runClipMove(
@@ -1279,7 +1274,6 @@ describe("commitDraggedClipMove", () => {
1279
1274
  const child: TimelineElement = {
1280
1275
  ...el("child", 2, childStart, 4),
1281
1276
  expandedHostKey: "host",
1282
- expandedParentStart: hostStart,
1283
1277
  };
1284
1278
  const { onMoveElements } = runClipMove(
1285
1279
  drag(child, { previewStart: childStart, previewTrack: 0 }),
@@ -1399,12 +1393,10 @@ describe("commitZMirrorLaneMove", () => {
1399
1393
  const child1 = {
1400
1394
  ...el("child-1", 0.25, 0, 5),
1401
1395
  sourceFile: "scene.html",
1402
- expandedParentStart: 0,
1403
1396
  };
1404
1397
  const child2 = {
1405
1398
  ...el("child-2", 0.5, 0, 5),
1406
1399
  sourceFile: "scene.html",
1407
- expandedParentStart: 0,
1408
1400
  };
1409
1401
  const b = { ...el("b", 1, 0, 5), sourceFile: "index.html" };
1410
1402
  const t = { ...el("t", 2, 0, 5), sourceFile: "index.html" };
@@ -1516,6 +1508,24 @@ describe("commitZMirrorLaneMove", () => {
1516
1508
  });
1517
1509
  });
1518
1510
 
1511
+ describe("persistMoveEdits: a nested row dropped before its host", () => {
1512
+ it("lands at the host's start in the store and in the persist", async () => {
1513
+ const logo = { ...el("logo", 0, 5, 5, "div"), parentCompositionStart: 2 };
1514
+ const updateElement = vi.fn();
1515
+ const onMoveElements = vi.fn(async (_edits: TimelineMoveEdit[]) => {});
1516
+ await persistMoveEdits([{ element: logo, updates: { start: 1, track: 0 } }], {
1517
+ elements: [logo],
1518
+ trackOrder: [0],
1519
+ updateElement,
1520
+ onMoveElements,
1521
+ });
1522
+ expect(updateElement).toHaveBeenCalledWith("logo", { start: 2, track: 0 });
1523
+ expect(onMoveElements.mock.calls[0]?.[0]).toEqual([
1524
+ { element: logo, updates: { start: 2, track: 0 } },
1525
+ ]);
1526
+ });
1527
+ });
1528
+
1519
1529
  describe("persistMoveEdits convergence", () => {
1520
1530
  it("reasserts a saved lane after a stale runtime sync", async () => {
1521
1531
  const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
@@ -1,4 +1,5 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
+ import { clampToHostStart } from "../store/timelineElement";
2
3
  import type { DraggedClipState } from "./useTimelineClipDrag";
3
4
  // Type-only: erased at runtime, so the timelineZMirror → timelineClipDragCommit
4
5
  // value-import edge stays acyclic.
@@ -107,6 +108,10 @@ export function persistMoveEdits(
107
108
  coalesceMs?: number,
108
109
  ): Promise<boolean> {
109
110
  if (edits.length === 0) return Promise.resolve(true);
111
+ edits = edits.map((e) => {
112
+ const start = clampToHostStart(e.element, e.updates.start);
113
+ return start === e.updates.start ? e : { ...e, updates: { ...e.updates, start } };
114
+ });
110
115
  const { updateElement, onMoveElement, onMoveElements } = deps;
111
116
  if (!onMoveElements) {
112
117
  console.warn(
@@ -7,7 +7,7 @@ import {
7
7
  type DragPreviewContext,
8
8
  } from "./timelineClipDragPreview";
9
9
  import type { DraggedClipState } from "./timelineClipDragTypes";
10
- import { commitDraggedClipMove } from "./timelineClipDragCommit";
10
+ import { commitDraggedClipMove, persistMoveEdits } from "./timelineClipDragCommit";
11
11
  import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
12
12
 
13
13
  // ─────────────────────────────────────────────────────────────────────────────
@@ -456,7 +456,6 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
456
456
  const child: TimelineElement = {
457
457
  ...clip("child", 2, childStart, 4, 5),
458
458
  expandedHostKey: "host",
459
- expandedParentStart: hostStart,
460
459
  };
461
460
  const elements = [host, child];
462
461
  const keys = new Set(["host", "child"]);
@@ -467,3 +466,72 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
467
466
  }
468
467
  });
469
468
  });
469
+
470
+ describe("a nested clip's drop stops at its host's start in the preview", () => {
471
+ // Host at 2 s: the clip may not start before 2 unless it was authored there.
472
+ const logo: TimelineElement = { ...clip("logo", 0, 5, 5, 0, "div"), parentCompositionStart: 2 };
473
+ const saved = async (element: TimelineElement, start: number) => {
474
+ const updateElement = vi.fn();
475
+ await persistMoveEdits([{ element, updates: { start, track: element.track } }], {
476
+ elements: [element],
477
+ trackOrder: [0, 1, 2],
478
+ updateElement,
479
+ onMoveElements: vi.fn(async () => {}),
480
+ });
481
+ return updateElement.mock.calls[0]?.[1]?.start;
482
+ };
483
+
484
+ it("holds a drop that snapping would put before the host, and saves the same start", async () => {
485
+ const { drag, clientX, clientY } = horizontalDrag(logo, 0.5, -3.9);
486
+ const next = computeDragPreview(drag, clientX, clientY, {
487
+ ...ctx(undefined, [logo]),
488
+ buildSnapTargets: () => [{ time: 1.9, type: "beat" }],
489
+ });
490
+ expect(next.previewStart).toBe(2);
491
+ expect(next.snapTime).toBeNull();
492
+ expect(await saved(logo, next.previewStart)).toBe(2);
493
+ });
494
+
495
+ it("snaps from the floor, so an edge just past the host's start still catches", () => {
496
+ const { drag, clientX, clientY } = horizontalDrag(logo, 0.5, -3.9);
497
+ const next = computeDragPreview(drag, clientX, clientY, {
498
+ ...ctx(undefined, [logo]),
499
+ buildSnapTargets: () => [{ time: 2.1, type: "beat" }],
500
+ });
501
+ expect(next).toMatchObject({ previewStart: 2.1, snapTime: 2.1 });
502
+ });
503
+
504
+ it("keeps a mixed group's spacing when the nested member meets its floor", () => {
505
+ // Grab the top-level member: only the group clamp knows the nested one's floor.
506
+ const outro = clip("outro", 1, 8, 2, 0, "div");
507
+ const { drag, clientX, clientY } = horizontalDrag(outro, 1.5, -7);
508
+ const next = computeDragPreview(drag, clientX, clientY, {
509
+ ...ctx(undefined, [logo, outro]),
510
+ selectedKeys: new Set(["logo", "outro"]),
511
+ });
512
+ expect(next.previewStart).toBe(5);
513
+ });
514
+
515
+ it("leaves a clip authored before its host's start in place when grabbed", async () => {
516
+ const early: TimelineElement = { ...logo, id: "early", key: "early", domId: "early", start: 1 };
517
+ const { drag, clientX, clientY } = horizontalDrag(early, 0.5, 0);
518
+ expect(computeDragPreview(drag, clientX, clientY, ctx(undefined, [early])).previewStart).toBe(
519
+ 1,
520
+ );
521
+ const trim = computeResizePreview(
522
+ {
523
+ element: early,
524
+ edge: "start",
525
+ originClientX: 0,
526
+ previewStart: 1,
527
+ previewDuration: 5,
528
+ started: true,
529
+ pointerId: 0,
530
+ },
531
+ 0,
532
+ { scroll: fakeScroll(), pps: PPS, buildSnapTargets: () => [] },
533
+ );
534
+ expect(trim).toMatchObject({ previewStart: 1, previewDuration: 5 });
535
+ expect(await saved(early, 1)).toBe(1);
536
+ });
537
+ });
@@ -5,6 +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
9
  import {
9
10
  getTimelineInsertBoundaryBand,
10
11
  getTimelineRowFromY,
@@ -80,10 +81,10 @@ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duratio
80
81
  /**
81
82
  * Rigid group move: when the grabbed clip is part of a multi-selection, the
82
83
  * WHOLE formation shifts by its delta on commit (see timelineClipDragCommit).
83
- * Clamp that delta here — against every selected member's start — so the
84
- * grabbed clip can't out-run the group: it STOPS the instant any member would
85
- * cross 0, exactly as it lands on commit. Lane changes still apply to the
86
- * grabbed clip only, so only the start (x) is constrained.
84
+ * Clamp that delta here, and a lone clip's as a group of one, so the grabbed
85
+ * clip STOPS the instant any member would cross its floor (clampToHostStart),
86
+ * exactly as it lands on commit. Lane changes still apply to the grabbed clip
87
+ * only, so only the start (x) is constrained.
87
88
  */
88
89
  function resolveGroupClampedStart(
89
90
  snapStart: number,
@@ -92,10 +93,10 @@ function resolveGroupClampedStart(
92
93
  elements: TimelineElement[],
93
94
  selectedKeys: ReadonlySet<string>,
94
95
  ): number {
95
- if (selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return snapStart;
96
- const memberStarts = elements.filter((e) => selectedKeys.has(e.key ?? e.id)).map((e) => e.start);
97
- const clampedDelta = clampGroupMoveDelta(snapStart - element.start, memberStarts);
98
- return element.start + clampedDelta;
96
+ const inGroup = selectedKeys.size > 1 && selectedKeys.has(dragKey);
97
+ const members = inGroup ? elements.filter((e) => selectedKeys.has(e.key ?? e.id)) : [element];
98
+ const floors = members.map((e) => ({ start: e.start, minStart: clampToHostStart(e, 0) }));
99
+ return element.start + clampGroupMoveDelta(snapStart - element.start, floors);
99
100
  }
100
101
 
101
102
  /**
@@ -173,6 +174,7 @@ export function computeDragPreview(
173
174
  originScrollLeft: drag.originScrollLeft,
174
175
  currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
175
176
  pixelsPerSecond: pps,
177
+ minStart: clampToHostStart(drag.element, 0),
176
178
  maxStart: dragMaxStart,
177
179
  trackOrder,
178
180
  },
@@ -226,8 +228,8 @@ export function computeDragPreview(
226
228
  // deliberate vertical lane change from a horizontal drag merely bumped sideways.
227
229
  desiredTrack: nextMove.track,
228
230
  insertRow,
229
- snapTime: snap.snapTime,
230
- snapType: snap.snapType,
231
+ snapTime: previewStart === snap.start ? snap.snapTime : null,
232
+ snapType: previewStart === snap.start ? snap.snapType : null,
231
233
  };
232
234
  }
233
235
 
@@ -293,7 +295,7 @@ export function computeResizePreview(
293
295
  duration: resize.element.duration,
294
296
  originClientX: resize.originClientX,
295
297
  pixelsPerSecond: pps,
296
- minStart: 0,
298
+ minStart: clampToHostStart(resize.element, 0),
297
299
  maxEnd,
298
300
  playbackStart:
299
301
  resize.edge === "start" && canSeedPlaybackStart
@@ -336,7 +338,8 @@ export function computeResizePreview(
336
338
  const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
337
339
  const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
338
340
  const delta = snapped - nextResize.start;
339
- const bounds = clipStartTrimDeltaBounds(clip, 0, resolveTimelineMinDuration());
341
+ const floor = clampToHostStart(resize.element, 0);
342
+ const bounds = clipStartTrimDeltaBounds(clip, floor, resolveTimelineMinDuration());
340
343
  if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
341
344
  if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
342
345
  snap = target;
@@ -51,6 +51,7 @@ export interface TimelineMoveInput {
51
51
  originScrollLeft?: number;
52
52
  currentScrollLeft?: number;
53
53
  pixelsPerSecond: number;
54
+ minStart?: number;
54
55
  maxStart: number;
55
56
  trackOrder: number[];
56
57
  layerOrder?: TimelineLayerId[];
@@ -127,7 +128,7 @@ export function resolveTimelineMove(
127
128
  const deltaTrack = Math.round(trackDeltaRaw);
128
129
  const nextStart = clamp(
129
130
  roundToCentiseconds(input.start + deltaTime),
130
- 0,
131
+ input.minStart ?? 0,
131
132
  Math.max(0, input.maxStart),
132
133
  );
133
134
 
@@ -1,6 +1,7 @@
1
1
  import { clampPlaybackRate } from "@hyperframes/parsers/media-duration";
2
2
  import { roundToCenti } from "../../utils/rounding";
3
3
  import type { TimelineElement } from "../store/playerStore";
4
+ import { clampToHostStart } from "../store/timelineElement";
4
5
  import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
5
6
 
6
7
  const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
@@ -88,6 +89,7 @@ export interface TimelineGroupTimingMember {
88
89
  duration: number;
89
90
  playbackStart?: number;
90
91
  playbackRate?: number;
92
+ minStart?: number;
91
93
  }
92
94
 
93
95
  export type TimelineGroupResizeEdge = "start" | "end";
@@ -107,7 +109,7 @@ function clampTimelineGroupMoveDelta(
107
109
  members: readonly TimelineGroupTimingMember[],
108
110
  ): number {
109
111
  if (members.length === 0) return 0;
110
- const minDelta = Math.max(...members.map((member) => -member.start));
112
+ const minDelta = Math.max(...members.map((member) => -(member.start - (member.minStart ?? 0))));
111
113
  return roundTimelineTime(Math.max(rawDelta, minDelta));
112
114
  }
113
115
 
@@ -139,7 +141,9 @@ export function clampTimelineGroupResizeDelta(
139
141
  }
140
142
 
141
143
  // Rigid group: the applied delta is bounded by the most-constrained member.
142
- const bounds = members.map((member) => clipStartTrimDeltaBounds(member, 0, minDuration));
144
+ const bounds = members.map((member) =>
145
+ clipStartTrimDeltaBounds(member, member.minStart ?? 0, minDuration),
146
+ );
143
147
  const minDelta = ceilTimelineTime(Math.max(...bounds.map((b) => b.minDelta)));
144
148
  const maxDelta = Math.min(...bounds.map((b) => b.maxDelta));
145
149
  const delta = roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
@@ -243,6 +247,7 @@ export function buildTimelineGroupResizeMembers(
243
247
  ? (element.playbackStart ?? 0)
244
248
  : element.playbackStart,
245
249
  playbackRate: element.playbackRate,
250
+ minStart: clampToHostStart(element, 0),
246
251
  }));
247
252
  }
248
253
 
@@ -17,6 +17,8 @@ const base = (over: Partial<MultiDragPreviewInput> = {}): MultiDragPreviewInput
17
17
  ...over,
18
18
  });
19
19
 
20
+ const starts = (...values: number[]) => values.map((start) => ({ start }));
21
+
20
22
  describe("isMultiDragActive", () => {
21
23
  it("is active when a started drag's clip is part of a 2+ selection", () => {
22
24
  expect(isMultiDragActive(base())).toBe(true);
@@ -94,34 +96,34 @@ describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)",
94
96
 
95
97
  describe("clampGroupMoveDelta (rigid group move)", () => {
96
98
  it("passes a rightward delta through unchanged (no right wall)", () => {
97
- expect(clampGroupMoveDelta(3, [2, 5, 9])).toBe(3);
98
- expect(clampGroupMoveDelta(1000, [0, 4])).toBe(1000);
99
+ expect(clampGroupMoveDelta(3, starts(2, 5, 9))).toBe(3);
100
+ expect(clampGroupMoveDelta(1000, starts(0, 4))).toBe(1000);
99
101
  });
100
102
 
101
103
  it("passes a leftward delta through when no member would cross 0", () => {
102
104
  // Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped.
103
- expect(clampGroupMoveDelta(-3, [5, 8, 12])).toBe(-3);
105
+ expect(clampGroupMoveDelta(-3, starts(5, 8, 12))).toBe(-3);
104
106
  });
105
107
 
106
108
  it("clamps a leftward delta so the leftmost member stops exactly at 0", () => {
107
109
  // Leftmost at 2 → the furthest left the group can move is -2 (that member → 0).
108
110
  // A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
109
111
  // formation instead of out-running it.
110
- expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2);
112
+ expect(clampGroupMoveDelta(-5, starts(2, 6, 10))).toBe(-2);
111
113
  });
112
114
 
113
115
  it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => {
114
116
  // Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1.
115
- expect(clampGroupMoveDelta(-8, [10, 1, 4])).toBe(-1);
117
+ expect(clampGroupMoveDelta(-8, starts(10, 1, 4))).toBe(-1);
116
118
  });
117
119
 
118
120
  it("already-at-0 member forbids any leftward move", () => {
119
- expect(clampGroupMoveDelta(-4, [0, 3, 7])).toBe(0);
121
+ expect(clampGroupMoveDelta(-4, starts(0, 3, 7))).toBe(0);
120
122
  // rightward still allowed
121
- expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2);
123
+ expect(clampGroupMoveDelta(2, starts(0, 3, 7))).toBe(2);
122
124
  });
123
125
 
124
126
  it("returns the raw delta for an empty formation", () => {
125
- expect(clampGroupMoveDelta(-9, [])).toBe(-9);
127
+ expect(clampGroupMoveDelta(-9, starts())).toBe(-9);
126
128
  });
127
129
  });
@@ -97,10 +97,13 @@ export function multiDragPassengerOffsetPx(
97
97
  * included). Only the lower bound (start ≥ 0) constrains a move; the timeline has
98
98
  * no fixed right wall (the composition grows on commit).
99
99
  */
100
- export function clampGroupMoveDelta(rawDelta: number, memberStarts: readonly number[]): number {
101
- if (memberStarts.length === 0) return rawDelta;
102
- // Leftmost member sets the floor: delta ≥ -min(start) keeps every start ≥ 0.
103
- const minStart = Math.min(...memberStarts);
104
- const minDelta = minStart === 0 ? 0 : -minStart; // avoid -0
100
+ export function clampGroupMoveDelta(
101
+ rawDelta: number,
102
+ members: readonly { start: number; minStart?: number }[],
103
+ ): number {
104
+ if (members.length === 0) return rawDelta;
105
+ // The member nearest its own floor bounds the move: delta ≥ -(start - minStart) for each.
106
+ const room = Math.min(...members.map((m) => m.start - (m.minStart ?? 0)));
107
+ const minDelta = room === 0 ? 0 : -room; // avoid -0
105
108
  return rawDelta < minDelta ? minDelta : rawDelta;
106
109
  }
@@ -27,6 +27,7 @@ function inputs(overrides: Partial<TimelineMetaBuilderInputs> = {}): TimelineMet
27
27
  ref: vi.fn(),
28
28
  tabIndex: -1,
29
29
  labelMode: false,
30
+ contentOrigin: 80,
30
31
  zoomMode: "fit",
31
32
  onScroll: vi.fn(),
32
33
  onFocus: vi.fn(),
@@ -46,9 +46,13 @@ type ContainerInputs = Omit<TimelineContainerProps, "className"> & {
46
46
 
47
47
  type ViewportInputs = Omit<
48
48
  TimelineViewportProps,
49
- "className" | "data-timeline-scroll-viewport" | "data-timeline-auto-scroll-left-inset"
49
+ | "className"
50
+ | "data-timeline-scroll-viewport"
51
+ | "data-timeline-auto-scroll-left-inset"
52
+ | "data-timeline-content-origin"
50
53
  > & {
51
54
  labelMode: boolean;
55
+ contentOrigin: number;
52
56
  zoomMode: "fit" | "manual";
53
57
  };
54
58
 
@@ -69,7 +73,7 @@ export function buildTimelineMeta({
69
73
  labelColumnWidth,
70
74
  razorGuide,
71
75
  }: TimelineMetaBuilderInputs): TimelineMeta {
72
- const { labelMode, zoomMode, ...viewportProps } = viewport;
76
+ const { labelMode, zoomMode, contentOrigin, ...viewportProps } = viewport;
73
77
  const containerClassName = `relative border-t select-none h-full overflow-hidden ${container.isDragOver ? container.accentClass : ""} ${container.activeTool === "razor" ? "cursor-crosshair" : container.shiftHeld ? "cursor-crosshair" : "cursor-default"}`;
74
78
  const viewportClassName = `${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-hidden`;
75
79
  return {
@@ -87,6 +91,7 @@ export function buildTimelineMeta({
87
91
  ...viewportProps,
88
92
  "data-timeline-scroll-viewport": true,
89
93
  "data-timeline-auto-scroll-left-inset": labelMode ? labelColumnWidth : 0,
94
+ "data-timeline-content-origin": contentOrigin,
90
95
  className: viewportClassName,
91
96
  },
92
97
  razorGuide,
@@ -622,7 +622,7 @@ describe("expanded sub-comp children — lane scoping", () => {
622
622
  const child = (id: string, track: number, start: number, duration: number) =>
623
623
  el(id, track, start, duration, {
624
624
  sourceFile: "scene.html",
625
- expandedParentStart: 5,
625
+ expandedHostKey: "host",
626
626
  });
627
627
 
628
628
  it("a child mirrors onto a SIBLING's lane and persists the sibling's AUTHORED track", () => {
@@ -630,7 +630,7 @@ describe("expanded sub-comp children — lane scoping", () => {
630
630
  // file) but sits elsewhere in time → its lane is free over t's span.
631
631
  const c1 = el("c1", 0.25, 20, 3, {
632
632
  sourceFile: "scene.html",
633
- expandedParentStart: 5,
633
+ expandedHostKey: "host",
634
634
  authoredTrack: 2,
635
635
  });
636
636
  const c2 = child("c2", 0.5, 5, 5);
@@ -102,7 +102,7 @@ function expandedChildAllowedLanes(
102
102
  element: TimelineElement,
103
103
  elements: TimelineElement[],
104
104
  ): ReadonlySet<number> | null {
105
- if (element.expandedParentStart == null) return null;
105
+ if (element.expandedHostKey == null) return null;
106
106
  const selfKey = keyOf(element);
107
107
  return new Set(
108
108
  elements
@@ -513,9 +513,9 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
513
513
  const h = renderResizeHarness([a], [], { onSeek });
514
514
  h.startResize(a, "end");
515
515
  h.movePointer(50);
516
- expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30, { keepPlaying: true });
516
+ expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30, { keepPlaying: true, follow: false });
517
517
  await h.dropPointer();
518
- expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true });
518
+ expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true, follow: false });
519
519
  h.unmount();
520
520
  });
521
521
 
@@ -525,7 +525,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
525
525
  const h = renderResizeHarness([a], [], { onSeek });
526
526
  h.startResize(a, "start");
527
527
  h.movePointer(50);
528
- expect(onSeek).toHaveBeenLastCalledWith(1.5, { keepPlaying: true });
528
+ expect(onSeek).toHaveBeenLastCalledWith(1.5, { keepPlaying: true, follow: false });
529
529
  h.unmount();
530
530
  });
531
531
 
@@ -545,7 +545,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
545
545
  h.movePointer(80);
546
546
  h.movePointer(120);
547
547
  await h.dropPointer();
548
- expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true });
548
+ expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true, follow: false });
549
549
  h.unmount();
550
550
  });
551
551
 
@@ -571,7 +571,10 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
571
571
  const clip = h.getResizingClip()!;
572
572
  expect(clip.previewStart + clip.previewDuration).toBeCloseTo(3.1, 3);
573
573
  expect(clip).toMatchObject({ snapTime: null, snapType: null });
574
- expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3), { keepPlaying: true });
574
+ expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3), {
575
+ keepPlaying: true,
576
+ follow: false,
577
+ });
575
578
  h.unmount();
576
579
  });
577
580
  });
@@ -66,7 +66,7 @@ interface UseTimelineClipDragInput {
66
66
  onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
67
67
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
68
68
  /** Seeks the preview; a trim shows the frame at its dragged edge. */
69
- onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
69
+ onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
70
70
  setShowPopover: (show: boolean) => void;
71
71
  /** Stable ref to the range selection setter — wired after mount to break circular dependency. */
72
72
  setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
@@ -311,6 +311,7 @@ export function useTimelineClipDrag({
311
311
  // and it only resumes playback if it was already playing.
312
312
  onSeekRef.current?.(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration), {
313
313
  keepPlaying: true,
314
+ follow: false,
314
315
  });
315
316
  publishResizingClip(
316
317
  resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
@@ -361,7 +362,7 @@ export function useTimelineClipDrag({
361
362
  if (trimSeekOriginRef.current != null) {
362
363
  // Paused: put the playhead back. Playing: leave it, a backward jump would rewind live playback.
363
364
  if (!usePlayerStore.getState().isPlaying) {
364
- onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true });
365
+ onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true, follow: false });
365
366
  }
366
367
  trimSeekOriginRef.current = null;
367
368
  }