@hyperframes/studio 0.8.91 → 0.8.93

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 (37) hide show
  1. package/dist/assets/{hyperframes-player-zT2Bdx_l.js → hyperframes-player-CkV9KK6A.js} +3 -3
  2. package/dist/assets/{index-DV-z19zB.js → index-BlLFipOk.js} +1 -1
  3. package/dist/assets/{index-V-0rfhe6.js → index-CYk0NwSv.js} +165 -165
  4. package/dist/assets/{index-DSOLcE8X.js → index-V3fzEfOD.js} +1 -1
  5. package/dist/index.d.ts +6 -11
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +8792 -8803
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  11. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  12. package/src/player/components/Timeline.rulerSeek.test.tsx +7 -7
  13. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  14. package/src/player/components/timelineClipDragCommit.test.ts +14 -58
  15. package/src/player/components/timelineClipDragCommit.ts +14 -37
  16. package/src/player/components/timelineClipDragPreview.test.ts +213 -22
  17. package/src/player/components/timelineClipDragPreview.ts +46 -81
  18. package/src/player/components/timelineClipDragTypes.ts +3 -8
  19. package/src/player/components/timelineCollision.test.ts +100 -302
  20. package/src/player/components/timelineCollision.ts +73 -160
  21. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  22. package/src/player/components/timelineDragDrop.ts +7 -9
  23. package/src/player/components/timelineDragLanding.ts +0 -49
  24. package/src/player/components/timelineLayout.test.ts +0 -25
  25. package/src/player/components/timelineLayout.ts +0 -16
  26. package/src/player/components/timelineMultiDragPreview.test.ts +34 -25
  27. package/src/player/components/timelineMultiDragPreview.ts +25 -27
  28. package/src/player/components/timelineProviderStateBuilders.ts +7 -4
  29. package/src/player/components/timelineShellMeta.tsx +99 -0
  30. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  31. package/src/player/components/useTimelineProviderState.tsx +29 -67
  32. package/src/player/hooks/useTimelinePlayer.seek.test.ts +29 -1
  33. package/src/player/hooks/useTimelinePlayer.ts +6 -0
  34. package/src/player/hooks/useTimelinePlayerLoop.ts +6 -2
  35. package/src/player/lib/playbackTypes.ts +1 -0
  36. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  37. package/src/utils/timelineDropTrackInsert.ts +36 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.91",
3
+ "version": "0.8.93",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/parsers": "0.8.91",
64
- "@hyperframes/player": "0.8.91",
65
- "@hyperframes/shader-transitions": "0.8.91",
66
- "@hyperframes/sdk": "0.8.91",
67
- "@hyperframes/studio-server": "0.8.91",
68
- "@hyperframes/core": "0.8.91"
63
+ "@hyperframes/core": "0.8.93",
64
+ "@hyperframes/shader-transitions": "0.8.93",
65
+ "@hyperframes/studio-server": "0.8.93",
66
+ "@hyperframes/parsers": "0.8.93",
67
+ "@hyperframes/player": "0.8.93",
68
+ "@hyperframes/sdk": "0.8.93"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.91"
84
+ "@hyperframes/producer": "0.8.93"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -6,6 +6,7 @@ import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
6
6
  import { mountReactHarness } from "./domSelectionTestHarness";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
8
  import type { TimelineElement } from "../player";
9
+ import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
9
10
 
10
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
12
 
@@ -140,6 +141,143 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
140
141
  expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
141
142
  });
142
143
 
144
+ // A clip shown on timeline row `row` and written on file track `track`.
145
+ const rowClip = (
146
+ id: string,
147
+ start: number,
148
+ duration: number,
149
+ shown: { tag?: string; row?: number; track?: number } = {},
150
+ ): TimelineElement => ({
151
+ id,
152
+ key: id,
153
+ tag: shown.tag ?? "div",
154
+ start,
155
+ duration,
156
+ track: shown.row ?? 0,
157
+ authoredTrack: shown.track ?? shown.row ?? 0,
158
+ hfId: `hf-${id}`,
159
+ domId: id,
160
+ });
161
+ const rowSource = (clips: TimelineElement[]) =>
162
+ [
163
+ '<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080">',
164
+ ...clips.map(
165
+ (c) =>
166
+ `<${c.tag} data-hf-id="hf-${c.id}" id="${c.id}" data-start="${c.start}" data-duration="${c.duration}" data-track-index="${c.authoredTrack}"></${c.tag}>`,
167
+ ),
168
+ "</main>",
169
+ ].join("\n");
170
+ const writtenClip = (written: string, tag: string) =>
171
+ written.match(new RegExp(`<${tag} id="[^"]*"[^>]*>`))?.[0] ?? "";
172
+
173
+ it("lands an asset dropped on an occupied row at that row's nearest free time", async () => {
174
+ const subtitle = rowClip("subtitle", 0, 6);
175
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
176
+ const getDrop = renderDropHook(rowSource([subtitle]), writeProjectFile, [subtitle]);
177
+
178
+ await act(async () => {
179
+ await getDrop()("clip.mp4", { start: 2, track: 0 }, 5);
180
+ });
181
+
182
+ const [, written] = writeProjectFile.mock.calls[0] as [string, string];
183
+ const clip = writtenClip(written, "video");
184
+ expect(clip).toContain('data-start="6"');
185
+ expect(clip).toContain('data-track-index="0"');
186
+ expect(written).toContain(
187
+ 'id="subtitle" data-start="0" data-duration="6" data-track-index="0"',
188
+ );
189
+ });
190
+
191
+ // Drops two files (3 s images by default) onto `clips`; returns each written clip's start and track.
192
+ async function twoFileDrop(
193
+ clips: TimelineElement[],
194
+ placement: TimelineDropPlacement,
195
+ [names, tag] = [["one.png", "two.png"], "img"] as [string[], string],
196
+ ) {
197
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
198
+ const uploadProjectFiles = vi.fn().mockResolvedValue(names);
199
+ const getDrop = renderDropHook(
200
+ rowSource(clips),
201
+ writeProjectFile,
202
+ clips,
203
+ undefined,
204
+ uploadProjectFiles,
205
+ );
206
+ const files = names.map((name) => new File([name], name));
207
+ await act(async () => {
208
+ await getDrop.file()(files, placement);
209
+ });
210
+ return writeProjectFile.mock.calls.map(([, written]) => {
211
+ const img = writtenClip(written as string, tag);
212
+ return [img.match(/data-start="([^"]*)"/)?.[1], img.match(/data-track-index="([^"]*)"/)?.[1]];
213
+ });
214
+ }
215
+
216
+ it("keeps a multi-file drop's clips off each other and off the row's clips", async () => {
217
+ const landed = await twoFileDrop([rowClip("a", 0, 4), rowClip("b", 8, 2)], {
218
+ start: 1,
219
+ track: 0,
220
+ });
221
+ // The first moves past a [0,4); the second is too long for [7,8) and goes after b.
222
+ expect(landed).toEqual([
223
+ ["4", "0"],
224
+ ["10", "0"],
225
+ ]);
226
+ });
227
+
228
+ it("puts a multi-file drop that opens a track back to back on that new track", async () => {
229
+ const landed = await twoFileDrop([rowClip("a", 0, 10)], {
230
+ start: 2,
231
+ track: 0,
232
+ insertRow: 0,
233
+ trackOrder: [0],
234
+ });
235
+ // Clip a moved down a row, so it no longer blocks the second file.
236
+ expect(landed).toEqual([
237
+ ["2", "0"],
238
+ ["5", "0"],
239
+ ]);
240
+ });
241
+
242
+ it("keeps a multi-file drop on the file track of the row it was dropped on", async () => {
243
+ const clips = [
244
+ rowClip("title", 0, 10, { track: 2 }),
245
+ rowClip("mid", 0, 1, { row: 1, track: 3 }),
246
+ rowClip("other", 0, 1, { row: 2, track: 5 }),
247
+ ];
248
+ // Row 0 is file track 2; the second file must not read that 2 back as row 2 (track 5).
249
+ expect(await twoFileDrop(clips, { start: 3, track: 0 })).toEqual([
250
+ ["10", "2"],
251
+ ["13", "2"],
252
+ ]);
253
+ });
254
+
255
+ it("reads a row's file track from every clip of the file, whether or not it names the file", async () => {
256
+ const unnamed = rowClip("unnamed", 0, 1, { row: 2, track: 0 });
257
+ const title = { ...rowClip("title", 0, 10), sourceFile: "index.html" };
258
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
259
+ const getDrop = renderDropHook(rowSource([unnamed, title]), writeProjectFile, [unnamed, title]);
260
+ await act(async () => {
261
+ await getDrop()("pic.png", { start: 2, track: 0 }, 3);
262
+ });
263
+ const img = writtenClip(writeProjectFile.mock.calls[0][1] as string, "img");
264
+ expect(img).toContain('data-track-index="0"');
265
+ expect(img).toContain('data-start="10"');
266
+ });
267
+
268
+ it("keeps two audio files dropped on a visual row together, as a single one would go", async () => {
269
+ const clips = [
270
+ rowClip("title", 0, 10, { track: 1 }),
271
+ rowClip("music", 2, 3, { tag: "audio", row: 1, track: 0 }),
272
+ ];
273
+ const landed = await twoFileDrop(clips, { start: 12, track: 0 }, [["a.mp3", "b.mp3"], "audio"]);
274
+ // Audio length probing times out in this harness, so each file gets the 5 s default.
275
+ expect(landed).toEqual([
276
+ ["12", "0"],
277
+ ["17", "0"],
278
+ ]);
279
+ }, 30_000);
280
+
143
281
  it("refuses an asset drop before reading or writing when editing is blocked", async () => {
144
282
  const writeProjectFile = vi.fn().mockResolvedValue(undefined);
145
283
  const checkEditable = vi.fn(() => false);
@@ -30,14 +30,20 @@ import { extendRootDurationInSource } from "../utils/rootDuration";
30
30
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
31
31
  import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
32
32
 
33
- /** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
33
+ /** The first uploaded file opens the new track (if asked); each next one aims right after the previous. */
34
34
  function fileDropPlacement(
35
35
  index: number,
36
36
  next: { start: number; track: number },
37
37
  dropped: TimelineDropPlacement | undefined,
38
- landedTrack: number | undefined,
38
+ previous: TimelineElement | undefined,
39
39
  ): TimelineDropPlacement {
40
- return index === 0 ? { ...dropped, ...next } : { ...next, track: landedTrack ?? next.track };
40
+ if (index === 0) return { ...dropped, ...next };
41
+ return previous ? { start: previous.start + previous.duration, track: previous.track } : next;
42
+ }
43
+
44
+ interface DropGesture {
45
+ placed: TimelineElement[];
46
+ onNewTrack: boolean;
41
47
  }
42
48
 
43
49
  function timelineDropTarget(
@@ -90,7 +96,8 @@ export function useTimelineAssetDropOps({
90
96
  assetPath: string,
91
97
  placement: TimelineDropPlacement,
92
98
  durationOverride?: number,
93
- ): Promise<number | undefined> => {
99
+ gesture: DropGesture = { placed: [], onNewTrack: false },
100
+ ): Promise<TimelineElement | undefined> => {
94
101
  if (isRecordingRef?.current) {
95
102
  showToast("Cannot edit timeline while recording", "error");
96
103
  return undefined;
@@ -121,28 +128,32 @@ export function useTimelineAssetDropOps({
121
128
  const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
122
129
 
123
130
  const resolvedTargetPath = targetPath || "index.html";
124
- const relevantElements = timelineElements.filter(
125
- (te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
126
- );
127
- const newElementZIndex = Math.max(1, relevantElements.length + 1);
131
+ const fileElements = timelineElements
132
+ .filter((te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath)
133
+ .map((te) => ({ ...te, sourceFile: resolvedTargetPath }));
134
+ const newElementZIndex = Math.max(1, fileElements.length + gesture.placed.length + 1);
135
+ const tag = kind === "image" ? "img" : kind;
128
136
 
129
137
  let newId = "";
130
138
  let track = 0;
139
+ let start = normalizedStart;
131
140
  const insertAsset = (originalContent: string) => {
132
141
  newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
133
142
  const resolved = resolveDropTrack({
134
143
  source: originalContent,
135
- // insertRow counts the rows the timeline shows, so plan against those.
136
- elements: relevantElements,
144
+ // Rows and insertRow count the rows the timeline shows; a track this drop opened has no others.
145
+ elements: gesture.onNewTrack ? [] : fileElements,
146
+ placed: gesture.placed,
137
147
  placement,
138
148
  dropped: {
139
149
  id: newId,
140
- tag: kind === "image" ? "img" : kind,
150
+ tag,
141
151
  start: normalizedStart,
142
152
  duration: normalizedDuration,
143
153
  },
144
154
  });
145
155
  track = resolved.track;
156
+ start = resolved.start;
146
157
  return extendRootDurationInSource(
147
158
  insertTimelineAssetIntoSource(
148
159
  resolved.source,
@@ -151,7 +162,7 @@ export function useTimelineAssetDropOps({
151
162
  hfId: `hf-${generateId()}`,
152
163
  assetPath: resolvedAssetSrc,
153
164
  kind,
154
- start: normalizedStart,
165
+ start,
155
166
  duration: normalizedDuration,
156
167
  track,
157
168
  zIndex: newElementZIndex,
@@ -162,7 +173,7 @@ export function useTimelineAssetDropOps({
162
173
  ),
163
174
  }),
164
175
  ),
165
- normalizedStart + normalizedDuration,
176
+ start + normalizedDuration,
166
177
  );
167
178
  };
168
179
 
@@ -178,7 +189,15 @@ export function useTimelineAssetDropOps({
178
189
  selectAndRevealTimelineElement(deriveTimelineStoreKeyForDomId(newId, targetPath));
179
190
  forceReloadSdkSession?.();
180
191
  reloadPreview();
181
- return track;
192
+ const lane = placement.insertRow == null ? placement.track : track;
193
+ return {
194
+ id: newId,
195
+ tag,
196
+ start,
197
+ duration: normalizedDuration,
198
+ track: lane,
199
+ authoredTrack: track,
200
+ };
182
201
  } catch (error) {
183
202
  const message =
184
203
  error instanceof Error ? error.message : "Failed to drop asset onto timeline";
@@ -234,15 +253,18 @@ export function useTimelineAssetDropOps({
234
253
  placement ?? { start: 0, track: 0 },
235
254
  durations,
236
255
  );
237
- let landedTrack: number | undefined;
256
+ const gesture: DropGesture = { placed: [], onNewTrack: false };
238
257
  for (const [index, assetPath] of uploaded.entries()) {
239
258
  const next = placements[index] ?? placements[0];
240
- const track = await dropAssetAt(
259
+ const clip = await dropAssetAt(
241
260
  assetPath,
242
- fileDropPlacement(index, next, placement, landedTrack),
261
+ fileDropPlacement(index, next, placement, gesture.placed.at(-1)),
243
262
  durations[index],
263
+ gesture,
244
264
  );
245
- if (index === 0) landedTrack = track;
265
+ if (!clip) continue;
266
+ gesture.placed.push(clip);
267
+ if (index === 0 && placement?.insertRow != null) gesture.onNewTrack = true;
246
268
  }
247
269
  },
248
270
  [
@@ -89,16 +89,16 @@ async function seekOnFirstTick(
89
89
 
90
90
  describe("Timeline ruler origin", () => {
91
91
  it.each([
92
- ["keyframes shown", { showKeyframes: true }, false],
93
- ["keyframes hidden", { showKeyframes: false }, false],
94
- ["keyframes shown, arriving after mount", { showKeyframes: true }, true],
95
- ["keyframes hidden, arriving after mount", { showKeyframes: false }, true],
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
96
  ])(
97
97
  "publishes where the ruler draws 0 s, and a press seeks by it (%s)",
98
- async (_case, props, late) => {
98
+ async (_case, props, late, expected) => {
99
99
  const { time, tickTime, origin, published } = await seekOnFirstTick(props, late);
100
- expect(origin).toBe("264px");
101
- expect(published).toBe("264");
100
+ expect(origin).toBe(`${expected}px`);
101
+ expect(published).toBe(String(expected));
102
102
  expect(time).toBeCloseTo(tickTime, 1);
103
103
  },
104
104
  );
@@ -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
  });
@@ -1187,73 +1187,26 @@ describe("commitDraggedClipMove", () => {
1187
1187
  });
1188
1188
  });
1189
1189
 
1190
- describe("magnetic main track on a track-insert", () => {
1191
- it("a top-gutter insert that renumbers to literal track 0 snaps its start to 0", () => {
1192
- // Sole visual clip sits on track 1 — the real main track (0) is genuinely
1193
- // empty. Dragging it into the top insert-gutter creates a new lane that
1194
- // normalizeToZones renumbers to literal 0, so the main-track rule must apply
1195
- // 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", () => {
1196
1192
  const elements = [el("v1", 1, 0, 5)];
1197
1193
  const { onMoveElements } = runClipMove(
1198
1194
  drag(elements[0], { previewStart: 8, previewTrack: 1, insertRow: 0 }),
1199
1195
  { elements, trackOrder: [1] },
1200
1196
  );
1201
- const map = editMap(onMoveElements.mock.calls[0][0]);
1202
- expect(map.v1).toEqual({ start: 0, track: 0 });
1203
- });
1204
-
1205
- it("the z-sync candidate reflects the snapped start, not the raw previewStart", async () => {
1206
- // v1 snaps from previewStart 8 to 0 (see the test above). A foreign-file
1207
- // sibling at [2, 5) only overlaps the SNAPPED window [0, 5), never the
1208
- // raw [8, 13) — so a z-sync firing at all proves the candidate the
1209
- // stacking check reads from carries the snapped start.
1210
- const v1 = el("v1", 2, 0, 5);
1211
- const sibling = el("s", 1, 2, 3); // [2, 5) — same source file, same paint scope
1212
- const elements = [v1, sibling];
1213
- const onStackingPatches = vi.fn();
1214
- // v1 lands on lane 0, which paints above lane 1 (lower track paints higher),
1215
- // yet carries the lower z: a violation only while the two overlap in time.
1216
- commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 2, insertRow: 0 }), {
1217
- elements,
1218
- trackOrder: [1, 2],
1219
- updateElement: vi.fn(),
1220
- onMoveElement: vi.fn(),
1221
- onMoveElements: vi.fn(),
1222
- readZIndex: (element) => (element.key === "v1" ? 1 : 10),
1223
- onStackingPatches,
1224
- });
1225
- await flushMicrotasks();
1226
- expect(onStackingPatches).toHaveBeenCalledTimes(1);
1197
+ expect(editMap(onMoveElements.mock.calls[0][0]).v1).toEqual({ start: 8, track: 0 });
1227
1198
  });
1228
1199
 
1229
- 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", () => {
1230
1201
  const elements = [el("v1", 1, 0, 5)];
1231
1202
  const spies = runClipMove(drag(elements[0], { previewStart: 8, previewTrack: 0 }), {
1232
1203
  elements,
1233
1204
  trackOrder: [0, 1],
1234
1205
  });
1235
- expect(expectAtomicMoveMap(spies).v1).toEqual({ start: 0, track: 0 });
1206
+ expect(expectAtomicMoveMap(spies).v1).toEqual({ start: 8, track: 0 });
1236
1207
  });
1237
1208
 
1238
- it("the z-sync candidate of a plain lane change carries the snapped start", async () => {
1239
- // The sibling [2, 5) overlaps v1 only at the snapped [0, 5), never at the raw [8, 13).
1240
- const v1 = el("v1", 2, 0, 5);
1241
- const sibling = el("s", 1, 2, 3);
1242
- const onStackingPatches = vi.fn();
1243
- commitDraggedClipMove(drag(v1, { previewStart: 8, previewTrack: 0 }), {
1244
- elements: [v1, sibling],
1245
- trackOrder: [0, 1, 2],
1246
- updateElement: vi.fn(),
1247
- onMoveElement: vi.fn(),
1248
- onMoveElements: vi.fn(),
1249
- readZIndex: (element) => (element.key === "v1" ? 1 : 10),
1250
- onStackingPatches,
1251
- });
1252
- await flushMicrotasks();
1253
- expect(onStackingPatches).toHaveBeenCalledTimes(1);
1254
- });
1255
-
1256
- 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", () => {
1257
1210
  const oldMain = el("old", 0, 0, 3);
1258
1211
  const dragged = el("v1", 2, 0, 5);
1259
1212
  const { onMoveElements } = runClipMove(
@@ -1262,10 +1215,10 @@ describe("commitDraggedClipMove", () => {
1262
1215
  );
1263
1216
  const map = editMap(onMoveElements.mock.calls[0][0]);
1264
1217
  expect(map.old.track).toBe(1);
1265
- expect(map.v1).toEqual({ start: 0, track: 0 });
1218
+ expect(map.v1).toEqual({ start: 8, track: 0 });
1266
1219
  });
1267
1220
 
1268
- 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", () => {
1269
1222
  for (const [hostStart, childStart] of [
1270
1223
  [30, 32],
1271
1224
  [20, 22],
@@ -1283,11 +1236,14 @@ describe("commitDraggedClipMove", () => {
1283
1236
  selectedKeys: new Set(["host", "child"]),
1284
1237
  },
1285
1238
  );
1286
- 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
+ });
1287
1243
  }
1288
1244
  });
1289
1245
 
1290
- 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", () => {
1291
1247
  const dragged = el("v1", 1, 0, 5);
1292
1248
  const sibling = el("v2", 1, 10, 5);
1293
1249
  const elements = [dragged, sibling];
@@ -1296,7 +1252,7 @@ describe("commitDraggedClipMove", () => {
1296
1252
  { elements, trackOrder: [1], selectedKeys: new Set(["v1", "v2"]) },
1297
1253
  );
1298
1254
  const map = editMap(onMoveElements.mock.calls[0][0]);
1299
- expect(map.v1.start).toBe(8); // unsnapped — multi-selection guard
1255
+ expect(map.v1.start).toBe(8);
1300
1256
  });
1301
1257
  });
1302
1258
  });
@@ -5,7 +5,7 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
5
5
  // value-import edge stays acyclic.
6
6
  import type { ZMirrorLaneMove } from "./timelineZMirror";
7
7
  import { classifyZone } from "./timelineZones";
8
- import { layoutAfterTrackInsert, resolveDragLandingStart } from "./timelineDragLanding";
8
+ import { layoutAfterTrackInsert } from "./timelineDragLanding";
9
9
  import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
10
10
  import {
11
11
  canMoveTimelineElement as canMoveElement,
@@ -19,6 +19,7 @@ import {
19
19
  import { runLaneZGesture } from "../../components/nle/zLaneGesture";
20
20
  import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
21
21
  import { authoredTrackForLane } from "./timelineAuthoredTrack";
22
+ import { resolveGroupMovers } from "./timelineMultiDragPreview";
22
23
 
23
24
  type StartTrack = Pick<TimelineElement, "start" | "track">;
24
25
  export interface TimelineMoveEdit {
@@ -63,9 +64,9 @@ export interface DragCommitDeps {
63
64
  * the edited clip(s) get z-index patches so their canvas stacking matches lane
64
65
  * order (higher lane = on top) relative to time-overlapping clips — see
65
66
  * timelineStackingSync. Both deps must be supplied to engage; if either is
66
- * absent the z-sync is skipped (pure time-moves and horizontal collision bumps
67
- * never restack). `readZIndex` returns the clip's current z-index (from the
68
- * 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).
69
70
  */
70
71
  readZIndex?: (element: TimelineElement) => number;
71
72
  /**
@@ -191,41 +192,20 @@ function resolveMultiSelection(
191
192
  keys: ReadonlySet<string>;
192
193
  movedStart: (e: TimelineElement) => number;
193
194
  } | null {
194
- const { elements, selectedKeys } = deps;
195
195
  const dragKey = keyOf(drag.element);
196
- if (!selectedKeys || selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return null;
197
- const keys = new Set(
198
- [...selectedKeys].filter((k) => {
199
- const el = elements.find((e) => keyOf(e) === k);
200
- return el ? canMoveElement(el) : false;
201
- }),
202
- );
196
+ const movers = resolveGroupMovers(deps.elements, deps.selectedKeys, dragKey);
197
+ if (!movers) return null;
198
+ const keys = new Set(movers.map(keyOf));
203
199
  const delta = drag.previewStart - drag.element.start;
204
200
  const movedStart = (e: TimelineElement): number =>
205
201
  keyOf(e) === dragKey ? drag.previewStart : Math.max(0, round3(e.start + delta));
206
202
  return { keys, movedStart };
207
203
  }
208
204
 
209
- /**
210
- * Commit a finished clip drag.
211
- *
212
- * The lane model is CapCut-stable: a clip's display lane is its track, and editing
213
- * ONE clip must never re-lane or rewrite OTHER clips. Three outcomes:
214
- *
215
- * - **Pure time-move** (dragged clip keeps its lane, no insert): persist just the
216
- * dragged clip's start (multi-selection shifts every selected clip in time).
217
- * - **Lane change / collision relocation** (the dragged clip's OWN lane changes,
218
- * no new track): persist ONLY the dragged clip's start + lane. No other clip is
219
- * touched. z is synced only when the gesture is a DELIBERATE vertical move
220
- * (the pointer aimed at another lane) — a horizontal drag merely bumped to a
221
- * free lane never restacks.
222
- * - **Track insert** (a new lane at a gap boundary): the dragged clip lands on
223
- * the new lane and the clips at/below the insert are renumbered by +1 (the ONLY
224
- * permitted multi-clip write) via a whole-set re-normalize; persisted atomically.
225
- */
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.
226
207
  // fallow-ignore-next-line complexity
227
- export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommitDeps): void {
228
- const drag = { ...rawDrag, previewStart: resolveDragLandingStart(rawDrag, deps) };
208
+ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
229
209
  const hostAlias = resolveExpandedHostAlias(drag, deps);
230
210
  if (hostAlias) {
231
211
  commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
@@ -236,11 +216,8 @@ export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommi
236
216
  const dragKey = keyOf(drag.element);
237
217
  const isInsert = drag.insertRow != null;
238
218
  const laneChanged = drag.previewTrack !== drag.element.track;
239
- // Deliberate VERTICAL gesture: the pointer aimed at a different lane, or at a
240
- // gap boundary (insert). A plain HORIZONTAL drag whose target span is occupied
241
- // gets the DRAGGED clip bumped to a free lane (previewTrack differs) while the
242
- // pointer never left its lane (desiredTrack === element.track) — that is NOT a
243
- // 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.
244
221
  const aimTrack = drag.desiredTrack ?? drag.previewTrack;
245
222
  const isVertical = isInsert || aimTrack !== drag.element.track;
246
223
  const multi = resolveMultiSelection(drag, deps);
@@ -279,7 +256,7 @@ export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommi
279
256
  return;
280
257
  }
281
258
 
282
- // ── Lane change / collision relocation: persist ONLY the dragged clip ────────
259
+ // ── Lane change: persist ONLY the dragged clip ──────────────────────────────
283
260
  // CapCut invariant — one edit never re-lanes another clip. The dragged clip
284
261
  // takes its new lane (previewTrack); the rest of any selection shifts in time
285
262
  // only. Nothing else is written.