@hyperframes/studio 0.7.56 → 0.7.57

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 (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. package/dist/assets/index-D-GyYi2d.css +0 -1
@@ -2,8 +2,11 @@
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  applyTimelineStackingReorder,
5
+ buildTimelineMoveTimingPatch,
5
6
  deleteSelectedKeyframes,
6
7
  extendRootDurationIfNeeded,
8
+ persistTimelineBatchEdit,
9
+ type PersistTimelineBatchChange,
7
10
  } from "./timelineEditingHelpers";
8
11
  import type { TimelineElement } from "../player/store/playerStore";
9
12
  import { usePlayerStore } from "../player/store/playerStore";
@@ -108,6 +111,94 @@ describe("extendRootDurationIfNeeded", () => {
108
111
  });
109
112
  });
110
113
 
114
+ describe("persistTimelineBatchEdit", () => {
115
+ const SOURCE = `<div id="root"><video id="a" class="clip" data-start="1" data-track-index="0"></video><video id="b" class="clip" data-start="2" data-track-index="1"></video></div>`;
116
+
117
+ function batchInput(changes: PersistTimelineBatchChange[], writes: Array<[string, string]>) {
118
+ return {
119
+ projectId: "p1",
120
+ activeCompPath: "index.html",
121
+ label: "Move timeline clips",
122
+ changes,
123
+ writeProjectFile: async (path: string, content: string) => {
124
+ writes.push([path, content]);
125
+ },
126
+ recordEdit: async () => {},
127
+ domEditSaveTimestampRef: { current: 0 },
128
+ pendingTimelineEditPathRef: { current: new Set<string>() },
129
+ };
130
+ }
131
+
132
+ function stubReadFileContent(content: string) {
133
+ vi.stubGlobal(
134
+ "fetch",
135
+ vi.fn(async () => ({
136
+ ok: true,
137
+ json: async () => ({ content }),
138
+ })),
139
+ );
140
+ }
141
+
142
+ function moveMember(
143
+ id: string,
144
+ start: number,
145
+ fromTrack: number,
146
+ toTrack: number,
147
+ ): PersistTimelineBatchChange {
148
+ return {
149
+ element: el({ id, tag: "video", domId: id, start, track: fromTrack }),
150
+ buildPatches: (original, target) =>
151
+ buildTimelineMoveTimingPatch(original, target, start, 5, toTrack),
152
+ };
153
+ }
154
+
155
+ async function runBatch(changes: PersistTimelineBatchChange[]) {
156
+ stubReadFileContent(SOURCE);
157
+ const writes: Array<[string, string]> = [];
158
+ await persistTimelineBatchEdit(batchInput(changes, writes));
159
+ return writes;
160
+ }
161
+
162
+ afterEach(() => {
163
+ vi.unstubAllGlobals();
164
+ });
165
+
166
+ it("skips no-op members instead of aborting the batch (track-insert renumber)", async () => {
167
+ // A track-insert renumber can include a member whose attributes already
168
+ // hold the target values — its patch is string-identical. The batch must
169
+ // skip it and still persist the members that DID change.
170
+ const writes = await runBatch([
171
+ // no-op: data-start already "1", track already 0
172
+ moveMember("a", 1, 0, 0),
173
+ // real change: track 1 -> 2
174
+ moveMember("b", 2, 1, 2),
175
+ ]);
176
+
177
+ expect(writes).toHaveLength(1);
178
+ expect(writes[0]![0]).toBe("index.html");
179
+ expect(writes[0]![1]).toContain('id="b" class="clip" data-start="2" data-track-index="2"');
180
+ });
181
+
182
+ it("saves nothing when every member is a no-op", async () => {
183
+ const writes = await runBatch([moveMember("a", 1, 0, 0)]);
184
+
185
+ expect(writes).toHaveLength(0);
186
+ });
187
+
188
+ it("throws on a mistargeted member instead of silently dropping it", async () => {
189
+ // A member whose target does not resolve in the source (stale id) patches
190
+ // to the identical string too — but that is a targeting FAILURE, not an
191
+ // already-at-target no-op, and must abort the batch like the single path.
192
+ stubReadFileContent(SOURCE);
193
+ const writes: Array<[string, string]> = [];
194
+
195
+ await expect(
196
+ persistTimelineBatchEdit(batchInput([moveMember("ghost", 3, 0, 2)], writes)),
197
+ ).rejects.toThrow("Unable to patch timeline element ghost in index.html");
198
+ expect(writes).toHaveLength(0);
199
+ });
200
+ });
201
+
111
202
  describe("deleteSelectedKeyframes", () => {
112
203
  it("coalesces all removals and reloads only after the last one", () => {
113
204
  usePlayerStore.setState({
@@ -1,15 +1,20 @@
1
1
  import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
2
- import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
2
+ import { applyPatchByTarget, findTagByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
3
3
  import {
4
4
  formatTimelineAttributeNumber,
5
5
  type TimelineStackingReorderIntent,
6
6
  } from "../player/components/timelineEditing";
7
7
  import { getElementZIndex } from "../player/lib/layerOrdering";
8
- import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers";
8
+ import {
9
+ furthestClipEndFromSource,
10
+ getTimelineElementIdentity,
11
+ } from "../player/lib/timelineElementHelpers";
9
12
  import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
10
13
  import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
11
- import { extendRootDurationInSource } from "../utils/rootDuration";
14
+ import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
15
+ import { readFileContent } from "./timelineTimingSync";
12
16
  export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
17
+ export { readFileContent };
13
18
  function isHTMLElement(element: Element | null): element is HTMLElement {
14
19
  if (!element) return false;
15
20
  // Use the element's OWN realm's HTMLElement: timeline clips live in the preview
@@ -147,22 +152,6 @@ export function patchIframeDomTiming(
147
152
  // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
148
153
  }
149
154
  }
150
- function postRootDurationToPreview(
151
- iframe: HTMLIFrameElement | null,
152
- durationSeconds: number,
153
- ): void {
154
- const duration = Number(durationSeconds);
155
- if (!Number.isFinite(duration) || duration <= 0) return;
156
- iframe?.contentWindow?.postMessage(
157
- {
158
- source: "hf-parent",
159
- type: "control",
160
- action: "set-root-duration",
161
- durationSeconds: duration,
162
- },
163
- "*",
164
- );
165
- }
166
155
  // fallow-ignore-next-line complexity
167
156
  function resolveResizePlaybackStart(
168
157
  original: string,
@@ -215,7 +204,12 @@ export function buildTimelineMoveTimingPatch(
215
204
  value: formatTimelineAttributeNumber(track),
216
205
  });
217
206
  }
218
- return extendRootDurationInSource(patched, start + duration);
207
+ // Content-driven duration: sync data-duration to the furthest clip end read
208
+ // from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved
209
+ // past the end and shrinks if the furthest clip moved left. Measured from the
210
+ // source, NOT the store — store durations are runtime-truncated to the current
211
+ // comp length, which would ratchet the duration down every move.
212
+ return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
219
213
  }
220
214
 
221
215
  export function buildTimelineResizeTimingPatch(
@@ -242,7 +236,10 @@ export function buildTimelineResizeTimingPatch(
242
236
  value: formatTimelineAttributeNumber(pbs.value),
243
237
  });
244
238
  }
245
- return extendRootDurationInSource(patched, updates.start + updates.duration);
239
+ // Content-driven duration from the PATCHED SOURCE (raw data-duration)
240
+ // grows/shrinks to the furthest clip end. Not from the store, whose
241
+ // durations are runtime-truncated.
242
+ return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
246
243
  }
247
244
 
248
245
  export interface PersistTimelineEditInput {
@@ -322,13 +319,24 @@ export async function persistTimelineBatchEdit(
322
319
  }
323
320
 
324
321
  const current = patchedByPath.get(targetPath) ?? original;
325
- const patched = change.buildPatches(current, patchTarget);
326
- if (patched === current) {
322
+ // Resolve the target FIRST: byte-identical output below is only a legit
323
+ // no-op when the member actually resolved in the source. A mistargeted
324
+ // member (stale id/selector) must fail loudly like the single-edit path,
325
+ // not be silently dropped as "already at target".
326
+ if (!findTagByTarget(current, patchTarget)) {
327
327
  throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
328
328
  }
329
+ const patched = change.buildPatches(current, patchTarget);
330
+ // The target resolved, so a member whose attributes already hold the target
331
+ // values patches to the identical string — e.g. a track-insert renumber
332
+ // where one clip's lane is already correct. That is a legitimate no-op:
333
+ // skip it instead of aborting (and rolling back) the whole batch.
334
+ if (patched === current) continue;
329
335
  patchedByPath.set(targetPath, patched);
330
336
  }
331
337
 
338
+ if (patchedByPath.size === 0) return;
339
+
332
340
  const files = Object.fromEntries(patchedByPath);
333
341
  for (const targetPath of Object.keys(files)) {
334
342
  input.pendingTimelineEditPathRef.current.add(targetPath);
@@ -347,227 +355,6 @@ export async function persistTimelineBatchEdit(
347
355
  input.domEditSaveTimestampRef.current = Date.now();
348
356
  }
349
357
 
350
- export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
351
- if (targetPath.includes("\0") || targetPath.includes("..")) {
352
- throw new Error(`Unsafe path: ${targetPath}`);
353
- }
354
- const response = await fetch(
355
- `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
356
- );
357
- if (!response.ok) {
358
- throw new Error(`Failed to read ${targetPath}`);
359
- }
360
- const data = (await response.json()) as { content?: string };
361
- if (typeof data.content !== "string") {
362
- throw new Error(`Missing file contents for ${targetPath}`);
363
- }
364
- return data.content;
365
- }
366
-
367
- export type GsapMutationStatus = { mutated: boolean };
368
-
369
- function isRecord(value: unknown): value is Record<string, unknown> {
370
- return typeof value === "object" && value !== null;
371
- }
372
-
373
- function readMutationStatus(value: unknown): GsapMutationStatus {
374
- if (!isRecord(value)) return { mutated: false };
375
- return { mutated: value.mutated === true || value.changed === true };
376
- }
377
-
378
- function readMutationError(value: unknown, fallback: string): string {
379
- if (isRecord(value) && typeof value.error === "string") return value.error;
380
- return fallback;
381
- }
382
-
383
- export async function finishTimelineTimingFallback(input: {
384
- iframe: HTMLIFrameElement | null;
385
- needsExtension: boolean;
386
- rootDurationSeconds: number;
387
- reloadPreview: () => void;
388
- gsapMutation?: () => Promise<GsapMutationStatus>;
389
- onGsapError: (error: unknown) => void;
390
- }): Promise<void> {
391
- let gsapMutated = false;
392
- if (input.gsapMutation) {
393
- try {
394
- gsapMutated = (await input.gsapMutation()).mutated;
395
- } catch (error) {
396
- input.onGsapError(error);
397
- return;
398
- }
399
- }
400
- if (input.needsExtension) {
401
- postRootDurationToPreview(input.iframe, input.rootDurationSeconds);
402
- if (gsapMutated) input.reloadPreview();
403
- return;
404
- }
405
- input.reloadPreview();
406
- }
407
-
408
- // Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
409
- // outlast one GSAP server round-trip, never a real second edit.
410
- const GSAP_HISTORY_COALESCE_MS = 10_000;
411
-
412
- /**
413
- * A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
414
- * timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
415
- * conflict. This snapshots every touched file, runs the mutation, then records a follow-up
416
- * edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
417
- * folding both writes into one undo step. Returns the mutation status for caller reloads.
418
- */
419
- export async function foldGsapMutationIntoHistory(input: {
420
- projectId: string;
421
- paths: string[];
422
- label: string;
423
- coalesceKey?: string;
424
- recordEdit: (edit: RecordEditInput) => Promise<void>;
425
- gsapMutation: () => Promise<GsapMutationStatus>;
426
- }): Promise<GsapMutationStatus> {
427
- const uniquePaths = [...new Set(input.paths)];
428
- const before = new Map<string, string>();
429
- for (const path of uniquePaths) {
430
- before.set(path, await readFileContent(input.projectId, path));
431
- }
432
- const status = await input.gsapMutation();
433
- if (status.mutated) {
434
- const files: Record<string, { before: string; after: string }> = {};
435
- for (const path of uniquePaths) {
436
- const priorContent = before.get(path);
437
- const finalContent = await readFileContent(input.projectId, path);
438
- if (priorContent !== undefined && finalContent !== priorContent) {
439
- files[path] = { before: priorContent, after: finalContent };
440
- }
441
- }
442
- if (Object.keys(files).length > 0) {
443
- await input.recordEdit({
444
- label: input.label,
445
- kind: "timeline",
446
- coalesceKey: input.coalesceKey,
447
- coalesceMs: GSAP_HISTORY_COALESCE_MS,
448
- files,
449
- });
450
- }
451
- }
452
- return status;
453
- }
454
-
455
- /**
456
- * Shift all GSAP animation positions targeting a given element by a time delta.
457
- * Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
458
- */
459
- export async function shiftGsapPositions(
460
- projectId: string,
461
- filePath: string,
462
- elementId: string,
463
- delta: number,
464
- ): Promise<GsapMutationStatus> {
465
- if (delta === 0 || !elementId) return { mutated: false };
466
- const res = await fetch(
467
- `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
468
- {
469
- method: "POST",
470
- headers: { "Content-Type": "application/json" },
471
- body: JSON.stringify({
472
- type: "shift-positions",
473
- targetSelector: `#${elementId}`,
474
- delta,
475
- }),
476
- },
477
- );
478
- if (!res.ok) {
479
- const err = await res.json().catch(() => null);
480
- throw new Error(readMutationError(err, "shift-positions failed"));
481
- }
482
- return readMutationStatus(await res.json().catch(() => null));
483
- }
484
-
485
- export async function scaleGsapPositions(
486
- projectId: string,
487
- filePath: string,
488
- elementId: string,
489
- oldStart: number,
490
- oldDuration: number,
491
- newStart: number,
492
- newDuration: number,
493
- ): Promise<GsapMutationStatus> {
494
- if (!elementId || oldDuration <= 0 || newDuration <= 0) return { mutated: false };
495
- if (oldStart === newStart && oldDuration === newDuration) return { mutated: false };
496
- const res = await fetch(
497
- `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
498
- {
499
- method: "POST",
500
- headers: { "Content-Type": "application/json" },
501
- body: JSON.stringify({
502
- type: "scale-positions",
503
- targetSelector: `#${elementId}`,
504
- oldStart,
505
- oldDuration,
506
- newStart,
507
- newDuration,
508
- }),
509
- },
510
- );
511
- if (!res.ok) {
512
- const err = await res.json().catch(() => null);
513
- throw new Error(readMutationError(err, "scale-positions failed"));
514
- }
515
- return readMutationStatus(await res.json().catch(() => null));
516
- }
517
-
518
- /** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */
519
- export function foldedShiftGsapMutation(input: {
520
- projectId: string;
521
- targetPath: string;
522
- domId: string;
523
- delta: number;
524
- label: string;
525
- coalesceKey?: string;
526
- recordEdit: (edit: RecordEditInput) => Promise<void>;
527
- }): () => Promise<GsapMutationStatus> {
528
- return () =>
529
- foldGsapMutationIntoHistory({
530
- projectId: input.projectId,
531
- paths: [input.targetPath],
532
- label: input.label,
533
- coalesceKey: input.coalesceKey,
534
- recordEdit: input.recordEdit,
535
- gsapMutation: () =>
536
- shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta),
537
- });
538
- }
539
-
540
- /** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */
541
- export function foldedScaleGsapMutation(input: {
542
- projectId: string;
543
- targetPath: string;
544
- domId: string;
545
- from: { start: number; duration: number };
546
- to: { start: number; duration: number };
547
- label: string;
548
- coalesceKey?: string;
549
- recordEdit: (edit: RecordEditInput) => Promise<void>;
550
- }): () => Promise<GsapMutationStatus> {
551
- return () =>
552
- foldGsapMutationIntoHistory({
553
- projectId: input.projectId,
554
- paths: [input.targetPath],
555
- label: input.label,
556
- coalesceKey: input.coalesceKey,
557
- recordEdit: input.recordEdit,
558
- gsapMutation: () =>
559
- scaleGsapPositions(
560
- input.projectId,
561
- input.targetPath,
562
- input.domId,
563
- input.from.start,
564
- input.from.duration,
565
- input.to.start,
566
- input.to.duration,
567
- ),
568
- });
569
- }
570
-
571
358
  export { applyPatchByTarget, formatTimelineAttributeNumber };
572
359
 
573
360
  export { patchDocumentRootDuration } from "./timelineEditingGsap";
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
4
4
 
5
+ type MoveArgs = Parameters<typeof persistTimelineMoveEditsAtomically>;
6
+
5
7
  const element = (id: string, track: number): TimelineElement => ({
6
8
  id,
7
9
  key: id,
@@ -11,64 +13,66 @@ const element = (id: string, track: number): TimelineElement => ({
11
13
  track,
12
14
  });
13
15
 
16
+ const twoLaneEdits = (bTrack: number): MoveArgs[0] => [
17
+ { element: element("a", 0), updates: { start: 1, track: 1 } },
18
+ { element: element("b", bTrack), updates: { start: 3, track: 2 } },
19
+ ];
20
+
21
+ const movedPair = (edits: MoveArgs[0]) => [
22
+ { element: edits[0].element, start: 1, track: 1 },
23
+ { element: edits[1].element, start: 3, track: 2 },
24
+ ];
25
+
26
+ const runMove = async (edits: MoveArgs[0], coalesceKey: MoveArgs[1], intent: MoveArgs[2]) => {
27
+ const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
28
+ await persistTimelineMoveEditsAtomically(edits, coalesceKey, intent, {
29
+ handleTimelineGroupMove,
30
+ });
31
+ return handleTimelineGroupMove;
32
+ };
33
+
14
34
  describe("persistTimelineMoveEditsAtomically", () => {
15
35
  it("persists two vertical edits as one group with the gesture coalesce key", async () => {
16
- const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
17
- const edits = [
18
- { element: element("a", 0), updates: { start: 1, track: 1 } },
19
- { element: element("b", 1), updates: { start: 3, track: 2 } },
20
- ];
21
- await persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "track-insert", {
22
- handleTimelineGroupMove,
23
- });
36
+ const edits = twoLaneEdits(1);
37
+ const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "track-insert");
24
38
  expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
25
- expect(handleTimelineGroupMove).toHaveBeenCalledWith(
26
- [
27
- { element: edits[0].element, start: 1, track: 1 },
28
- { element: edits[1].element, start: 3, track: 2 },
29
- ],
30
- { coalesceKey: "clip-lane-move:7" },
31
- );
39
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
40
+ coalesceKey: "clip-lane-move:7",
41
+ });
32
42
  });
33
43
 
34
- it("does not persist track attrs for a single z-only lane reorder", async () => {
35
- const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
36
- const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
37
- await persistTimelineMoveEditsAtomically([edit], "clip-lane-move:7", "lane-reorder", {
38
- handleTimelineGroupMove,
39
- });
44
+ it("omits track attrs for plain timing moves (keeps the SDK fast path eligible)", async () => {
45
+ const edit = { element: element("a", 0), updates: { start: 1, track: 0 } };
46
+ const handleTimelineGroupMove = await runMove([edit], undefined, "timing");
40
47
  expect(handleTimelineGroupMove).toHaveBeenCalledWith([{ element: edit.element, start: 1 }], {
41
- coalesceKey: "clip-lane-move:7",
48
+ coalesceKey: undefined,
42
49
  });
43
50
  });
44
51
 
45
- it("does not persist track attrs for a multi-selection lane drag", async () => {
46
- const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
47
- const edits = [
48
- { element: element("a", 0), updates: { start: 1, track: 1 } },
49
- { element: element("b", 2), updates: { start: 3, track: 2 } },
50
- ];
51
- await persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "lane-reorder", {
52
- handleTimelineGroupMove,
53
- });
52
+ it("persists the track attr for a single lane reorder (stable track lanes)", async () => {
53
+ // Lane = authored data-track-index; a vertical move that never hits disk
54
+ // snaps back on the next normalize, so the lane change MUST persist.
55
+ const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
56
+ const handleTimelineGroupMove = await runMove([edit], "clip-lane-move:7", "lane-reorder");
54
57
  expect(handleTimelineGroupMove).toHaveBeenCalledWith(
55
- [
56
- { element: edits[0].element, start: 1 },
57
- { element: edits[1].element, start: 3 },
58
- ],
58
+ [{ element: edit.element, start: 1, track: 1 }],
59
59
  { coalesceKey: "clip-lane-move:7" },
60
60
  );
61
61
  });
62
62
 
63
+ it("persists track attrs for a multi-selection lane drag (stable track lanes)", async () => {
64
+ const edits = twoLaneEdits(2);
65
+ const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "lane-reorder");
66
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
67
+ coalesceKey: "clip-lane-move:7",
68
+ });
69
+ });
70
+
63
71
  it("rejects without retrying individual members when the atomic batch fails", async () => {
64
72
  const failure = new Error("batch failed");
65
73
  const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure);
66
- const edits = [
67
- { element: element("a", 0), updates: { start: 1, track: 1 } },
68
- { element: element("b", 1), updates: { start: 3, track: 2 } },
69
- ];
70
74
  await expect(
71
- persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "track-insert", {
75
+ persistTimelineMoveEditsAtomically(twoLaneEdits(1), "clip-lane-move:7", "track-insert", {
72
76
  handleTimelineGroupMove,
73
77
  }),
74
78
  ).rejects.toBe(failure);
@@ -28,9 +28,11 @@ export function persistTimelineMoveEditsAtomically(
28
28
  edits.map(({ element, updates }) => ({
29
29
  element,
30
30
  start: updates.start,
31
- // A single vertical edit is the z-only reorder path. Multi-edit gestures
32
- // are track inserts/ripples and must persist every resulting lane index.
33
- track: operation === "track-insert" ? updates.track : undefined,
31
+ // Stable track lanes: a lane is the authored data-track-index, so every
32
+ // vertical gesture (lane-reorder AND track-insert) must persist the track;
33
+ // z is paint order only and is synced separately. Plain horizontal moves
34
+ // ("timing") omit it so they stay eligible for the SDK fast path.
35
+ track: operation === "timing" ? undefined : updates.track,
34
36
  })),
35
37
  { coalesceKey },
36
38
  );