@hyperframes/studio 0.8.90 → 0.8.92

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 (102) hide show
  1. package/dist/assets/{hyperframes-player-DDjER_ii.js → hyperframes-player-DKJPDHUp.js} +1 -1
  2. package/dist/assets/{index-BKE3_N4v.js → index-C5_j5-3u.js} +178 -178
  3. package/dist/assets/{index-5SFeUXP8.js → index-CHZ4kFWm.js} +1 -1
  4. package/dist/assets/{index-CVsCxnlz.js → index-DpzpqJ_j.js} +1 -1
  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 +18 -14
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +9190 -8916
  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/useLayerRevealOverride.test.ts +8 -0
  21. package/src/components/editor/useLayerRevealOverride.ts +2 -1
  22. package/src/components/nle/NLEContext.tsx +1 -1
  23. package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
  24. package/src/hooks/domEditPersistFailure.ts +8 -6
  25. package/src/hooks/elementOffsetStager.ts +65 -0
  26. package/src/hooks/gestureTransaction.ts +4 -2
  27. package/src/hooks/gsapDragCommit.ts +1 -0
  28. package/src/hooks/gsapEditOutcome.ts +2 -1
  29. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +50 -14
  31. package/src/hooks/gsapShared.test.ts +1 -1
  32. package/src/hooks/gsapShared.ts +16 -12
  33. package/src/hooks/gsapTweenReach.ts +30 -0
  34. package/src/hooks/timelineEditingHelpers.ts +2 -1
  35. package/src/hooks/useClipboard.source.test.ts +58 -0
  36. package/src/hooks/useClipboard.ts +12 -3
  37. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  38. package/src/hooks/useDomEditCommits.ts +2 -0
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
  40. package/src/hooks/useDomEditSession.ts +2 -0
  41. package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
  42. package/src/hooks/useDomGeometryCommit.ts +42 -1
  43. package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
  44. package/src/hooks/useDomGeometryCommits.ts +14 -1
  45. package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
  46. package/src/hooks/useGsapAwareEditing.ts +44 -7
  47. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
  48. package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
  49. package/src/hooks/useRazorSplit.test.ts +5 -5
  50. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  51. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  52. package/src/hooks/useTimelineEditing.test.tsx +384 -0
  53. package/src/hooks/useTimelineEditing.ts +12 -12
  54. package/src/hooks/useTimelineGroupEditing.ts +16 -7
  55. package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
  56. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  57. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  58. package/src/player/components/TimelineProvider.tsx +1 -0
  59. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  60. package/src/player/components/TimelineTypes.ts +1 -1
  61. package/src/player/components/timelineClipDragCommit.test.ts +32 -66
  62. package/src/player/components/timelineClipDragCommit.ts +19 -37
  63. package/src/player/components/timelineClipDragPreview.test.ts +283 -24
  64. package/src/player/components/timelineClipDragPreview.ts +51 -83
  65. package/src/player/components/timelineClipDragTypes.ts +3 -8
  66. package/src/player/components/timelineCollision.test.ts +100 -302
  67. package/src/player/components/timelineCollision.ts +73 -160
  68. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  69. package/src/player/components/timelineDragDrop.ts +7 -9
  70. package/src/player/components/timelineDragLanding.ts +0 -49
  71. package/src/player/components/timelineEditing.ts +2 -1
  72. package/src/player/components/timelineGroupEditing.ts +7 -2
  73. package/src/player/components/timelineLayout.test.ts +0 -25
  74. package/src/player/components/timelineLayout.ts +0 -16
  75. package/src/player/components/timelineMultiDragPreview.test.ts +34 -23
  76. package/src/player/components/timelineMultiDragPreview.ts +26 -25
  77. package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
  78. package/src/player/components/timelineProviderStateBuilders.ts +14 -6
  79. package/src/player/components/timelineShellMeta.tsx +99 -0
  80. package/src/player/components/timelineZMirror.test.ts +2 -2
  81. package/src/player/components/timelineZMirror.ts +1 -1
  82. package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
  83. package/src/player/components/useTimelineClipDrag.ts +3 -2
  84. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  85. package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
  86. package/src/player/components/useTimelinePlayhead.ts +56 -27
  87. package/src/player/components/useTimelineProviderState.tsx +29 -66
  88. package/src/player/components/useTimelineZoom.ts +9 -1
  89. package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -4
  91. package/src/player/lib/timelineDOM.test.ts +79 -0
  92. package/src/player/lib/timelineDOM.ts +16 -7
  93. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  94. package/src/player/lib/timelineIframeHelpers.ts +12 -3
  95. package/src/player/store/liveTime.ts +6 -0
  96. package/src/player/store/playerStore.ts +2 -0
  97. package/src/player/store/timelineElement.ts +28 -3
  98. package/src/utils/razorSplitTransaction.test.ts +1 -1
  99. package/src/utils/razorSplitTransaction.ts +3 -3
  100. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  101. package/src/utils/timelineDropTrackInsert.ts +36 -5
  102. package/dist/chunk-KU4F7QZ3.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  // fallow-ignore-file complexity
2
2
  import { useCallback, useRef } from "react";
3
- import type { TimelineElement } from "../player";
4
- import { usePlayerStore } from "../player";
3
+ import { usePlayerStore, type TimelineElement } from "../player";
4
+ import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
5
5
  import { useRazorSplit } from "./useRazorSplit";
6
6
  import { selectSplittableElements } from "../utils/timelineElementSplit";
7
7
  import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
@@ -187,12 +187,11 @@ export function useTimelineEditing({
187
187
  // other move — early-returning on !startChanged alone silently dropped
188
188
  // the file write, so the lane snapped back on reload.
189
189
  const trackChanged = updates.track !== element.track;
190
-
190
+ const authoredStart = toAuthoredStart(element, updates.start);
191
191
  if (startChanged || trackChanged) {
192
192
  const liveAttrs: Array<[string, string]> = [];
193
- if (startChanged) {
194
- liveAttrs.push(["data-start", formatTimelineAttributeNumber(updates.start)]);
195
- }
193
+ if (startChanged)
194
+ liveAttrs.push(["data-start", formatTimelineAttributeNumber(authoredStart)]);
196
195
  if (trackChanged) {
197
196
  liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
198
197
  }
@@ -224,7 +223,7 @@ export function useTimelineEditing({
224
223
  return buildTimelineMoveTimingPatch(
225
224
  original,
226
225
  target,
227
- updates.start,
226
+ authoredStart,
228
227
  element.duration,
229
228
  track,
230
229
  );
@@ -258,7 +257,7 @@ export function useTimelineEditing({
258
257
  return sdkTimingPersist(
259
258
  element.hfId,
260
259
  targetPath,
261
- { start: updates.start },
260
+ { start: authoredStart },
262
261
  sdkSession,
263
262
  {
264
263
  editHistory: { recordEdit },
@@ -309,8 +308,9 @@ export function useTimelineEditing({
309
308
  element: TimelineElement,
310
309
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
311
310
  ) => {
311
+ const authoredStart = toAuthoredStart(element, updates.start);
312
312
  const liveAttrs: Array<[string, string]> = [
313
- ["data-start", formatTimelineAttributeNumber(updates.start)],
313
+ ["data-start", formatTimelineAttributeNumber(authoredStart)],
314
314
  ["data-duration", formatTimelineAttributeNumber(updates.duration)],
315
315
  ];
316
316
  if (updates.playbackStart != null) {
@@ -350,8 +350,8 @@ export function useTimelineEditing({
350
350
  writeProjectFile,
351
351
  edit: {
352
352
  kind: "scale",
353
- from: { start: element.start, duration: element.duration },
354
- to: { start: updates.start, duration: updates.duration },
353
+ from: { start: toCompositionTime(element, element.start), duration: element.duration },
354
+ to: { start: toCompositionTime(element, updates.start), duration: updates.duration },
355
355
  },
356
356
  }).finally(() => invalidateGsapCache?.());
357
357
  const resizeFallback = () =>
@@ -363,7 +363,7 @@ export function useTimelineEditing({
363
363
  ? sdkTimingPersist(
364
364
  element.hfId,
365
365
  targetPath,
366
- { start: updates.start, duration: updates.duration },
366
+ { start: authoredStart, duration: updates.duration },
367
367
  sdkSession,
368
368
  {
369
369
  editHistory: { recordEdit },
@@ -3,6 +3,7 @@
3
3
  import { useCallback, type MutableRefObject, type RefObject } from "react";
4
4
  import type { Composition } from "@hyperframes/sdk";
5
5
  import type { TimelineElement } from "../player";
6
+ import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
6
7
  import {
7
8
  cutoverCommittedOrThrow,
8
9
  sdkTimingBatchPersist,
@@ -244,7 +245,10 @@ export function useTimelineGroupEditing({
244
245
  if (changes.length === 0) return Promise.resolve();
245
246
  for (const change of changes) {
246
247
  const attrs: Array<[string, string]> = [
247
- ["data-start", formatTimelineAttributeNumber(change.start)],
248
+ [
249
+ "data-start",
250
+ formatTimelineAttributeNumber(toAuthoredStart(change.element, change.start)),
251
+ ],
248
252
  ];
249
253
  if (change.track != null) {
250
254
  attrs.push(["data-track-index", formatTimelineAttributeNumber(change.track)]);
@@ -287,7 +291,9 @@ export function useTimelineGroupEditing({
287
291
  await options?.beforeTiming;
288
292
  const handledBySdk = await trySdkBatchPersist({
289
293
  changes,
290
- sdkChanges: toSdkTimingChanges(changes, (change) => ({ start: change.start })),
294
+ sdkChanges: toSdkTimingChanges(changes, (change) => ({
295
+ start: toAuthoredStart(change.element, change.start),
296
+ })),
291
297
  eligible: changes.every((change) => change.track == null),
292
298
  needsExtension,
293
299
  label,
@@ -304,7 +310,7 @@ export function useTimelineGroupEditing({
304
310
  buildTimelineMoveTimingPatch(
305
311
  original,
306
312
  target,
307
- change.start,
313
+ toAuthoredStart(change.element, change.start),
308
314
  change.element.duration,
309
315
  change.track,
310
316
  ),
@@ -375,7 +381,10 @@ export function useTimelineGroupEditing({
375
381
  if (changes.length === 0) return Promise.resolve();
376
382
  for (const change of changes) {
377
383
  const liveAttrs: Array<[string, string]> = [
378
- ["data-start", formatTimelineAttributeNumber(change.start)],
384
+ [
385
+ "data-start",
386
+ formatTimelineAttributeNumber(toAuthoredStart(change.element, change.start)),
387
+ ],
379
388
  ["data-duration", formatTimelineAttributeNumber(change.duration)],
380
389
  ];
381
390
  if (change.playbackStart != null) {
@@ -402,7 +411,7 @@ export function useTimelineGroupEditing({
402
411
  const handledBySdk = await trySdkBatchPersist({
403
412
  changes,
404
413
  sdkChanges: toSdkTimingChanges(changes, (change) => ({
405
- start: change.start,
414
+ start: toAuthoredStart(change.element, change.start),
406
415
  duration: change.duration,
407
416
  })),
408
417
  eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)),
@@ -453,9 +462,9 @@ export function useTimelineGroupEditing({
453
462
  projectId,
454
463
  changePath,
455
464
  domId,
456
- change.element.start,
465
+ toCompositionTime(change.element, change.element.start),
457
466
  change.element.duration,
458
- change.start,
467
+ toCompositionTime(change.element, change.start),
459
468
  change.duration,
460
469
  );
461
470
  },
@@ -0,0 +1,111 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
6
+ import { Timeline } from "./Timeline";
7
+ import { installTimelineMountEnv } from "./timelineMountTestEnv";
8
+ import { usePlayerStore } from "../store/playerStore";
9
+
10
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ installTimelineMountEnv();
13
+
14
+ beforeAll(() => {
15
+ HTMLElement.prototype.setPointerCapture = vi.fn();
16
+ HTMLElement.prototype.releasePointerCapture = vi.fn();
17
+ });
18
+
19
+ afterEach(() => {
20
+ document.body.innerHTML = "";
21
+ });
22
+
23
+ const KEYFRAMED = new Map([
24
+ [
25
+ "card",
26
+ [
27
+ {
28
+ id: "card-position",
29
+ targetSelector: "#card",
30
+ method: "to" as const,
31
+ position: 0,
32
+ duration: 2,
33
+ properties: {},
34
+ propertyGroup: "position" as const,
35
+ keyframes: {
36
+ format: "percentage" as const,
37
+ keyframes: [
38
+ { percentage: 0, properties: { x: 0 } },
39
+ { percentage: 50, properties: { x: 100 } },
40
+ ],
41
+ },
42
+ },
43
+ ],
44
+ ],
45
+ ]);
46
+
47
+ /** Presses the ruler exactly on the first drawn tick after 0 and returns its time and the seek. */
48
+ async function seekOnFirstTick(
49
+ props: { showKeyframes?: boolean },
50
+ keyframesLate: boolean,
51
+ animations: typeof KEYFRAMED | Map<string, never[]> = KEYFRAMED,
52
+ ) {
53
+ usePlayerStore.setState({
54
+ duration: 10,
55
+ currentTime: 0,
56
+ timelineReady: true,
57
+ elements: [{ id: "card", label: "Hero card", tag: "div", start: 0, duration: 10, track: 0 }],
58
+ gsapAnimations: keyframesLate ? new Map() : animations,
59
+ });
60
+ const onSeek = vi.fn();
61
+ const host = document.createElement("div");
62
+ document.body.append(host);
63
+ const root = createRoot(host);
64
+ await act(async () => root.render(<Timeline {...props} onSeek={onSeek} />));
65
+ if (keyframesLate) await act(async () => usePlayerStore.setState({ gsapAnimations: animations }));
66
+
67
+ const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
68
+ viewport.getBoundingClientRect = () =>
69
+ ({ left: 0, top: 0, right: 900, bottom: 400, width: 900, height: 400 }) as DOMRect;
70
+ const label = host.querySelector<HTMLElement>('[data-timeline-grid-cell="major"] span')!;
71
+ const band = label.parentElement!.parentElement!;
72
+ const tick = band.querySelectorAll<HTMLElement>('[data-timeline-grid-cell="major"]')[1]!;
73
+ const [, mm, ss] = /(\d+):(\d+)/.exec(tick.textContent ?? "")!;
74
+ const tickTime = Number(mm) * 60 + Number(ss);
75
+ // The ruler's corner block is as wide as the origin the ruler draws 0 s at.
76
+ const origin = (band.previousElementSibling as HTMLElement).style.width;
77
+ const clientX = parseFloat(origin) + parseFloat(tick.style.left) + 0.5;
78
+ const at = { bubbles: true, clientX, clientY: 4, button: 0, pointerId: 1 };
79
+ await act(async () => tick.dispatchEvent(new PointerEvent("pointerdown", at)));
80
+ await act(async () => tick.dispatchEvent(new PointerEvent("pointerup", at)));
81
+ act(() => root.unmount());
82
+ return {
83
+ time: onSeek.mock.calls.at(-1)?.[0] as number | undefined,
84
+ tickTime,
85
+ origin,
86
+ published: viewport.getAttribute("data-timeline-content-origin"),
87
+ };
88
+ }
89
+
90
+ describe("Timeline ruler origin", () => {
91
+ it.each([
92
+ ["keyframes shown", { showKeyframes: true }, false, 264],
93
+ ["keyframes hidden", { showKeyframes: false }, false, 80],
94
+ ["keyframes shown, arriving after mount", { showKeyframes: true }, true, 264],
95
+ ["keyframes hidden, arriving after mount", { showKeyframes: false }, true, 80],
96
+ ])(
97
+ "publishes where the ruler draws 0 s, and a press seeks by it (%s)",
98
+ async (_case, props, late, expected) => {
99
+ const { time, tickTime, origin, published } = await seekOnFirstTick(props, late);
100
+ expect(origin).toBe(`${expected}px`);
101
+ expect(published).toBe(String(expected));
102
+ expect(time).toBeCloseTo(tickTime, 1);
103
+ },
104
+ );
105
+
106
+ it("publishes the narrow origin when no clip needs the label column", async () => {
107
+ const { origin, published } = await seekOnFirstTick({}, false, new Map());
108
+ expect(origin).toBe("80px");
109
+ expect(published).toBe("80");
110
+ });
111
+ });
@@ -84,8 +84,34 @@ describe("Timeline showKeyframes", () => {
84
84
  expect((await mountKeyframed(false)).lanes).toBe(false);
85
85
  });
86
86
 
87
- it("keeps each track's name in the header when keyframes are off", async () => {
87
+ it("opens the track-name column for a keyframed clip only while keyframes are shown", async () => {
88
88
  expect((await mountKeyframed()).named).toBe(true);
89
- expect((await mountKeyframed(false)).named).toBe(true);
89
+ expect((await mountKeyframed(false)).named).toBe(false);
90
+ });
91
+
92
+ it("still opens the column for an audio group when keyframes are off", async () => {
93
+ usePlayerStore.setState({
94
+ duration: 10,
95
+ timelineReady: true,
96
+ gsapAnimations: new Map(),
97
+ elements: [
98
+ {
99
+ id: "vo",
100
+ label: "Voice",
101
+ tag: "audio",
102
+ start: 0,
103
+ duration: 4,
104
+ track: 0,
105
+ audioGroup: "g",
106
+ },
107
+ ],
108
+ });
109
+ const host = document.createElement("div");
110
+ document.body.append(host);
111
+ const root = createRoot(host);
112
+ await act(async () => root.render(<Timeline showKeyframes={false} />));
113
+ const corner = host.querySelector<HTMLElement>(".sticky.top-0.flex > div");
114
+ expect(corner?.style.width).toBe("264px");
115
+ act(() => root.unmount());
90
116
  });
91
117
  });
@@ -189,7 +189,7 @@ describe("TimelinePropertyLanes", () => {
189
189
  // host-absolute start while its tweens are parsed from its own file and are
190
190
  // local to it. clipTimingStart is what brings the two into one frame.
191
191
  it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
192
- const child = { start: 16.5, duration: 2, expandedParentStart: 16 };
192
+ const child = { start: 16.5, duration: 2, parentCompositionStart: 16 };
193
193
  const local = animation("pill-tween", "position", [
194
194
  { percentage: 0, properties: { x: 0 } },
195
195
  { percentage: 100, properties: { x: 100 } },
@@ -127,6 +127,7 @@ export interface TimelineViewportProps {
127
127
  ref: RefCallback<HTMLDivElement>;
128
128
  "data-timeline-scroll-viewport": boolean;
129
129
  "data-timeline-auto-scroll-left-inset": number;
130
+ "data-timeline-content-origin": number;
130
131
  tabIndex: number;
131
132
  className: string;
132
133
  onScroll: (event: UIEvent<HTMLDivElement>) => void;
@@ -209,7 +209,7 @@ describe("TimelineTrackHeader", () => {
209
209
  start: 16.5,
210
210
  duration: 2,
211
211
  track: 0,
212
- expandedParentStart: 16,
212
+ parentCompositionStart: 16,
213
213
  sourceFile: "scene.html",
214
214
  };
215
215
  const local: GsapAnimation = {
@@ -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(
@@ -1192,73 +1187,26 @@ describe("commitDraggedClipMove", () => {
1192
1187
  });
1193
1188
  });
1194
1189
 
1195
- describe("magnetic main track on a track-insert", () => {
1196
- it("a top-gutter insert that renumbers to literal track 0 snaps its start to 0", () => {
1197
- // Sole visual clip sits on track 1 — the real main track (0) is genuinely
1198
- // empty. Dragging it into the top insert-gutter creates a new lane that
1199
- // normalizeToZones renumbers to literal 0, so the main-track rule must apply
1200
- // even though the preview never saw a literal-0 landing track.
1190
+ describe("a clip landing on an empty main track keeps its released start", () => {
1191
+ it("a top-gutter insert that renumbers to literal track 0 commits at the released start", () => {
1201
1192
  const elements = [el("v1", 1, 0, 5)];
1202
1193
  const { onMoveElements } = runClipMove(
1203
1194
  drag(elements[0], { previewStart: 8, previewTrack: 1, insertRow: 0 }),
1204
1195
  { elements, trackOrder: [1] },
1205
1196
  );
1206
- const map = editMap(onMoveElements.mock.calls[0][0]);
1207
- expect(map.v1).toEqual({ start: 0, track: 0 });
1208
- });
1209
-
1210
- it("the z-sync candidate reflects the snapped start, not the raw previewStart", async () => {
1211
- // v1 snaps from previewStart 8 to 0 (see the test above). A foreign-file
1212
- // sibling at [2, 5) only overlaps the SNAPPED window [0, 5), never the
1213
- // raw [8, 13) — so a z-sync firing at all proves the candidate the
1214
- // stacking check reads from carries the snapped start.
1215
- const v1 = el("v1", 2, 0, 5);
1216
- const sibling = el("s", 1, 2, 3); // [2, 5) — same source file, same paint scope
1217
- const elements = [v1, sibling];
1218
- const onStackingPatches = vi.fn();
1219
- // v1 lands on lane 0, which paints above lane 1 (lower track paints higher),
1220
- // yet carries the lower z: a violation only while the two overlap in time.
1221
- commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 2, insertRow: 0 }), {
1222
- elements,
1223
- trackOrder: [1, 2],
1224
- updateElement: vi.fn(),
1225
- onMoveElement: vi.fn(),
1226
- onMoveElements: vi.fn(),
1227
- readZIndex: (element) => (element.key === "v1" ? 1 : 10),
1228
- onStackingPatches,
1229
- });
1230
- await flushMicrotasks();
1231
- expect(onStackingPatches).toHaveBeenCalledTimes(1);
1197
+ expect(editMap(onMoveElements.mock.calls[0][0]).v1).toEqual({ start: 8, track: 0 });
1232
1198
  });
1233
1199
 
1234
- it("a plain lane change onto the empty main track snaps its committed start to 0", () => {
1200
+ it("a plain lane change onto the empty main track commits at the released start", () => {
1235
1201
  const elements = [el("v1", 1, 0, 5)];
1236
1202
  const spies = runClipMove(drag(elements[0], { previewStart: 8, previewTrack: 0 }), {
1237
1203
  elements,
1238
1204
  trackOrder: [0, 1],
1239
1205
  });
1240
- expect(expectAtomicMoveMap(spies).v1).toEqual({ start: 0, track: 0 });
1241
- });
1242
-
1243
- it("the z-sync candidate of a plain lane change carries the snapped start", async () => {
1244
- // The sibling [2, 5) overlaps v1 only at the snapped [0, 5), never at the raw [8, 13).
1245
- const v1 = el("v1", 2, 0, 5);
1246
- const sibling = el("s", 1, 2, 3);
1247
- const onStackingPatches = vi.fn();
1248
- commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 0 }), {
1249
- elements: [v1, sibling],
1250
- trackOrder: [0, 1, 2],
1251
- updateElement: vi.fn(),
1252
- onMoveElement: vi.fn(),
1253
- onMoveElements: vi.fn(),
1254
- readZIndex: (element) => (element.key === "v1" ? 1 : 10),
1255
- onStackingPatches,
1256
- });
1257
- await flushMicrotasks();
1258
- expect(onStackingPatches).toHaveBeenCalledTimes(1);
1206
+ expect(expectAtomicMoveMap(spies).v1).toEqual({ start: 8, track: 0 });
1259
1207
  });
1260
1208
 
1261
- it("a top-gutter insert that pushes the old track-0 clip down snaps to the new track 0", () => {
1209
+ it("a top-gutter insert that pushes the old track-0 clip down keeps the released start", () => {
1262
1210
  const oldMain = el("old", 0, 0, 3);
1263
1211
  const dragged = el("v1", 2, 0, 5);
1264
1212
  const { onMoveElements } = runClipMove(
@@ -1267,10 +1215,10 @@ describe("commitDraggedClipMove", () => {
1267
1215
  );
1268
1216
  const map = editMap(onMoveElements.mock.calls[0][0]);
1269
1217
  expect(map.old.track).toBe(1);
1270
- expect(map.v1).toEqual({ start: 0, track: 0 });
1218
+ expect(map.v1).toEqual({ start: 8, track: 0 });
1271
1219
  });
1272
1220
 
1273
- it("an expanded child dragged with its host commits the HOST at 0", () => {
1221
+ it("an expanded child dragged with its host onto the main track keeps the host's start", () => {
1274
1222
  for (const [hostStart, childStart] of [
1275
1223
  [30, 32],
1276
1224
  [20, 22],
@@ -1279,7 +1227,6 @@ describe("commitDraggedClipMove", () => {
1279
1227
  const child: TimelineElement = {
1280
1228
  ...el("child", 2, childStart, 4),
1281
1229
  expandedHostKey: "host",
1282
- expandedParentStart: hostStart,
1283
1230
  };
1284
1231
  const { onMoveElements } = runClipMove(
1285
1232
  drag(child, { previewStart: childStart, previewTrack: 0 }),
@@ -1289,11 +1236,14 @@ describe("commitDraggedClipMove", () => {
1289
1236
  selectedKeys: new Set(["host", "child"]),
1290
1237
  },
1291
1238
  );
1292
- expect(editMap(onMoveElements.mock.calls[0][0]).host).toEqual({ start: 0, track: 0 });
1239
+ expect(editMap(onMoveElements.mock.calls[0][0]).host).toEqual({
1240
+ start: hostStart,
1241
+ track: 0,
1242
+ });
1293
1243
  }
1294
1244
  });
1295
1245
 
1296
- it("a multi-selection top-gutter insert does NOT snap (siblings key off the unsnapped start)", () => {
1246
+ it("a multi-selection top-gutter insert keeps the grabbed clip's start", () => {
1297
1247
  const dragged = el("v1", 1, 0, 5);
1298
1248
  const sibling = el("v2", 1, 10, 5);
1299
1249
  const elements = [dragged, sibling];
@@ -1302,7 +1252,7 @@ describe("commitDraggedClipMove", () => {
1302
1252
  { elements, trackOrder: [1], selectedKeys: new Set(["v1", "v2"]) },
1303
1253
  );
1304
1254
  const map = editMap(onMoveElements.mock.calls[0][0]);
1305
- expect(map.v1.start).toBe(8); // unsnapped — multi-selection guard
1255
+ expect(map.v1.start).toBe(8);
1306
1256
  });
1307
1257
  });
1308
1258
  });
@@ -1399,12 +1349,10 @@ describe("commitZMirrorLaneMove", () => {
1399
1349
  const child1 = {
1400
1350
  ...el("child-1", 0.25, 0, 5),
1401
1351
  sourceFile: "scene.html",
1402
- expandedParentStart: 0,
1403
1352
  };
1404
1353
  const child2 = {
1405
1354
  ...el("child-2", 0.5, 0, 5),
1406
1355
  sourceFile: "scene.html",
1407
- expandedParentStart: 0,
1408
1356
  };
1409
1357
  const b = { ...el("b", 1, 0, 5), sourceFile: "index.html" };
1410
1358
  const t = { ...el("t", 2, 0, 5), sourceFile: "index.html" };
@@ -1516,6 +1464,24 @@ describe("commitZMirrorLaneMove", () => {
1516
1464
  });
1517
1465
  });
1518
1466
 
1467
+ describe("persistMoveEdits: a nested row dropped before its host", () => {
1468
+ it("lands at the host's start in the store and in the persist", async () => {
1469
+ const logo = { ...el("logo", 0, 5, 5, "div"), parentCompositionStart: 2 };
1470
+ const updateElement = vi.fn();
1471
+ const onMoveElements = vi.fn(async (_edits: TimelineMoveEdit[]) => {});
1472
+ await persistMoveEdits([{ element: logo, updates: { start: 1, track: 0 } }], {
1473
+ elements: [logo],
1474
+ trackOrder: [0],
1475
+ updateElement,
1476
+ onMoveElements,
1477
+ });
1478
+ expect(updateElement).toHaveBeenCalledWith("logo", { start: 2, track: 0 });
1479
+ expect(onMoveElements.mock.calls[0]?.[0]).toEqual([
1480
+ { element: logo, updates: { start: 2, track: 0 } },
1481
+ ]);
1482
+ });
1483
+ });
1484
+
1519
1485
  describe("persistMoveEdits convergence", () => {
1520
1486
  it("reasserts a saved lane after a stale runtime sync", async () => {
1521
1487
  const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
@@ -1,10 +1,11 @@
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.
5
6
  import type { ZMirrorLaneMove } from "./timelineZMirror";
6
7
  import { classifyZone } from "./timelineZones";
7
- import { layoutAfterTrackInsert, resolveDragLandingStart } from "./timelineDragLanding";
8
+ import { layoutAfterTrackInsert } from "./timelineDragLanding";
8
9
  import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
9
10
  import {
10
11
  canMoveTimelineElement as canMoveElement,
@@ -18,6 +19,7 @@ import {
18
19
  import { runLaneZGesture } from "../../components/nle/zLaneGesture";
19
20
  import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
20
21
  import { authoredTrackForLane } from "./timelineAuthoredTrack";
22
+ import { resolveGroupMovers } from "./timelineMultiDragPreview";
21
23
 
22
24
  type StartTrack = Pick<TimelineElement, "start" | "track">;
23
25
  export interface TimelineMoveEdit {
@@ -62,9 +64,9 @@ export interface DragCommitDeps {
62
64
  * the edited clip(s) get z-index patches so their canvas stacking matches lane
63
65
  * order (higher lane = on top) relative to time-overlapping clips — see
64
66
  * timelineStackingSync. Both deps must be supplied to engage; if either is
65
- * absent the z-sync is skipped (pure time-moves and horizontal collision bumps
66
- * never restack). `readZIndex` returns the clip's current z-index (from the
67
- * live DOM inline style / computed; "auto" ⇒ 0).
67
+ * absent the z-sync is skipped (pure time-moves never restack). `readZIndex`
68
+ * returns the clip's current z-index (from the live DOM inline style / computed;
69
+ * "auto" ⇒ 0).
68
70
  */
69
71
  readZIndex?: (element: TimelineElement) => number;
70
72
  /**
@@ -107,6 +109,10 @@ export function persistMoveEdits(
107
109
  coalesceMs?: number,
108
110
  ): Promise<boolean> {
109
111
  if (edits.length === 0) return Promise.resolve(true);
112
+ edits = edits.map((e) => {
113
+ const start = clampToHostStart(e.element, e.updates.start);
114
+ return start === e.updates.start ? e : { ...e, updates: { ...e.updates, start } };
115
+ });
110
116
  const { updateElement, onMoveElement, onMoveElements } = deps;
111
117
  if (!onMoveElements) {
112
118
  console.warn(
@@ -186,41 +192,20 @@ function resolveMultiSelection(
186
192
  keys: ReadonlySet<string>;
187
193
  movedStart: (e: TimelineElement) => number;
188
194
  } | null {
189
- const { elements, selectedKeys } = deps;
190
195
  const dragKey = keyOf(drag.element);
191
- if (!selectedKeys || selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return null;
192
- const keys = new Set(
193
- [...selectedKeys].filter((k) => {
194
- const el = elements.find((e) => keyOf(e) === k);
195
- return el ? canMoveElement(el) : false;
196
- }),
197
- );
196
+ const movers = resolveGroupMovers(deps.elements, deps.selectedKeys, dragKey);
197
+ if (!movers) return null;
198
+ const keys = new Set(movers.map(keyOf));
198
199
  const delta = drag.previewStart - drag.element.start;
199
200
  const movedStart = (e: TimelineElement): number =>
200
201
  keyOf(e) === dragKey ? drag.previewStart : Math.max(0, round3(e.start + delta));
201
202
  return { keys, movedStart };
202
203
  }
203
204
 
204
- /**
205
- * Commit a finished clip drag.
206
- *
207
- * The lane model is CapCut-stable: a clip's display lane is its track, and editing
208
- * ONE clip must never re-lane or rewrite OTHER clips. Three outcomes:
209
- *
210
- * - **Pure time-move** (dragged clip keeps its lane, no insert): persist just the
211
- * dragged clip's start (multi-selection shifts every selected clip in time).
212
- * - **Lane change / collision relocation** (the dragged clip's OWN lane changes,
213
- * no new track): persist ONLY the dragged clip's start + lane. No other clip is
214
- * touched. z is synced only when the gesture is a DELIBERATE vertical move
215
- * (the pointer aimed at another lane) — a horizontal drag merely bumped to a
216
- * free lane never restacks.
217
- * - **Track insert** (a new lane at a gap boundary): the dragged clip lands on
218
- * the new lane and the clips at/below the insert are renumbered by +1 (the ONLY
219
- * permitted multi-clip write) via a whole-set re-normalize; persisted atomically.
220
- */
205
+ // A move writes the dragged clip's start (each moving clip's, in a group); a lane change also writes its lane and
206
+ // re-lanes no other clip; only a new track renumbers the rows below it.
221
207
  // fallow-ignore-next-line complexity
222
- export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommitDeps): void {
223
- const drag = { ...rawDrag, previewStart: resolveDragLandingStart(rawDrag, deps) };
208
+ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
224
209
  const hostAlias = resolveExpandedHostAlias(drag, deps);
225
210
  if (hostAlias) {
226
211
  commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
@@ -231,11 +216,8 @@ export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommi
231
216
  const dragKey = keyOf(drag.element);
232
217
  const isInsert = drag.insertRow != null;
233
218
  const laneChanged = drag.previewTrack !== drag.element.track;
234
- // Deliberate VERTICAL gesture: the pointer aimed at a different lane, or at a
235
- // gap boundary (insert). A plain HORIZONTAL drag whose target span is occupied
236
- // gets the DRAGGED clip bumped to a free lane (previewTrack differs) while the
237
- // pointer never left its lane (desiredTrack === element.track) — that is NOT a
238
- // vertical move: it must neither rewrite other clips nor touch z.
219
+ // Deliberate VERTICAL gesture: the pointer aimed at a different lane, or at the
220
+ // empty space outside the rows (insert). Anything else never touches z.
239
221
  const aimTrack = drag.desiredTrack ?? drag.previewTrack;
240
222
  const isVertical = isInsert || aimTrack !== drag.element.track;
241
223
  const multi = resolveMultiSelection(drag, deps);
@@ -274,7 +256,7 @@ export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommi
274
256
  return;
275
257
  }
276
258
 
277
- // ── Lane change / collision relocation: persist ONLY the dragged clip ────────
259
+ // ── Lane change: persist ONLY the dragged clip ──────────────────────────────
278
260
  // CapCut invariant — one edit never re-lanes another clip. The dragged clip
279
261
  // takes its new lane (previewTrack); the rest of any selection shifts in time
280
262
  // only. Nothing else is written.