@hyperframes/studio 0.8.81 → 0.8.83

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
  2. package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
  3. package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
  4. package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-LPZMAGS7.js +1871 -0
  10. package/dist/chunk-LPZMAGS7.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +834 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +26378 -28802
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  29. package/src/components/TimelineHistoryButtons.tsx +26 -14
  30. package/src/components/TimelineToolbar.test.tsx +9 -1
  31. package/src/components/TimelineToolbar.tsx +13 -5
  32. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  33. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  34. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  35. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  36. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  37. package/src/components/editor/DomEditOverlay.tsx +36 -21
  38. package/src/components/editor/LayersPanel.tsx +1 -1
  39. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  40. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  41. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  42. package/src/components/editor/clipAudioClock.test.ts +61 -0
  43. package/src/components/editor/clipAudioClock.ts +78 -0
  44. package/src/components/editor/manualEdits.test.ts +24 -0
  45. package/src/components/editor/manualEdits.ts +6 -2
  46. package/src/components/editor/manualEditsDom.ts +0 -65
  47. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  48. package/src/components/editor/marqueeCommit.ts +100 -37
  49. package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
  51. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  52. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  53. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  54. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  55. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  56. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  57. package/src/components/editor/useFxCarve.ts +11 -15
  58. package/src/components/editor/useFxCarveNodes.ts +27 -15
  59. package/src/components/editor/useFxLevelling.ts +20 -31
  60. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  61. package/src/components/feedback/projectProvenance.ts +2 -3
  62. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  63. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  64. package/src/components/nle/PreviewOverlays.tsx +7 -188
  65. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  66. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  67. package/src/components/pointerTargetSize.test.tsx +1 -1
  68. package/src/components/studioColorGradingScope.ts +12 -36
  69. package/src/components/ui/HyperframesLogo.tsx +120 -0
  70. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  71. package/src/contexts/TimelineEditContext.tsx +27 -23
  72. package/src/domEditExports.test.tsx +69 -0
  73. package/src/editorGesturesExports.test.tsx +236 -0
  74. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  75. package/src/hooks/gsapMutationClient.ts +116 -0
  76. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  77. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  78. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  79. package/src/hooks/timelineEditingHelpers.ts +66 -61
  80. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  81. package/src/hooks/timelineTimingSync.test.ts +12 -0
  82. package/src/hooks/timelineTimingSync.ts +31 -123
  83. package/src/hooks/timelineTrackVisibility.ts +15 -29
  84. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  85. package/src/hooks/useAskAgentModal.ts +3 -1
  86. package/src/hooks/useClipboard.source.test.ts +32 -0
  87. package/src/hooks/useClipboard.ts +21 -20
  88. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  89. package/src/hooks/useDomEditCommits.ts +88 -97
  90. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  91. package/src/hooks/useEditorSave.ts +1 -1
  92. package/src/hooks/useElementLifecycleOps.ts +37 -38
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  94. package/src/hooks/useFileManager.ts +21 -12
  95. package/src/hooks/useGroupCommits.ts +23 -24
  96. package/src/hooks/useGsapAnimationOps.ts +26 -8
  97. package/src/hooks/useGsapScriptCommits.ts +1 -0
  98. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  99. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  100. package/src/hooks/usePreviewPersistence.ts +1 -1
  101. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  102. package/src/hooks/useThumbnailStripSize.ts +31 -0
  103. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  104. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  105. package/src/hooks/useTimelineEditing.ts +3 -1
  106. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  107. package/src/index.ts +53 -32
  108. package/src/lightEntries.test.ts +32 -0
  109. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  110. package/src/player/components/CompositionThumbnail.tsx +6 -21
  111. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  112. package/src/player/components/ImageThumbnail.tsx +4 -18
  113. package/src/player/components/PlayerControls.test.tsx +52 -0
  114. package/src/player/components/PlayerControls.tsx +2 -1
  115. package/src/player/components/Timeline.test.ts +28 -3
  116. package/src/player/components/TimelineCanvas.tsx +5 -3
  117. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  118. package/src/player/components/TimelineClipFades.tsx +2 -1
  119. package/src/player/components/TimelineTypes.ts +4 -2
  120. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  121. package/src/player/components/VideoThumbnail.tsx +7 -29
  122. package/src/player/components/studioShortcuts.ts +8 -5
  123. package/src/player/components/thumbnailUtils.test.ts +16 -20
  124. package/src/player/components/thumbnailUtils.ts +6 -7
  125. package/src/player/components/timelineClipChildren.test.ts +34 -1
  126. package/src/player/components/timelineClipChildren.tsx +7 -4
  127. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  128. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  129. package/src/player/components/timelineEditing.test.ts +55 -0
  130. package/src/player/components/timelineGroupEditing.ts +13 -6
  131. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  132. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  133. package/src/player/components/timelinePointerRelease.ts +4 -0
  134. package/src/player/components/useTimelinePlayhead.ts +26 -8
  135. package/src/player/components/useTimelineProviderState.tsx +4 -0
  136. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  137. package/src/player/components/useTimelineStackingSync.ts +18 -11
  138. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  139. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  140. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  141. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  142. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  143. package/src/player/lib/playbackShortcuts.ts +18 -0
  144. package/src/player/lib/playbackTypes.ts +1 -0
  145. package/src/player/lib/timelineDOM.test.ts +92 -0
  146. package/src/player/lib/timelineDOM.ts +0 -2
  147. package/src/player/lib/timelineElementHelpers.ts +47 -10
  148. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  149. package/src/player/store/timelineElement.ts +2 -1
  150. package/src/playerEntry.ts +1 -0
  151. package/src/timelineChromeExports.test.tsx +79 -0
  152. package/src/timelineStackingSyncExport.test.tsx +67 -0
  153. package/src/ui.ts +31 -0
  154. package/src/utils/authoredSource.ts +15 -0
  155. package/src/utils/blockInstaller.ts +32 -27
  156. package/src/utils/mediaTypes.test.ts +33 -0
  157. package/src/utils/mediaTypes.ts +12 -5
  158. package/src/utils/studioFileHistory.test.ts +14 -14
  159. package/src/utils/studioFileHistory.ts +31 -2
  160. package/dist/chunk-I7XNAIOF.js.map +0 -1
  161. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
@@ -15,7 +15,10 @@ import { getElementZIndex } from "../player/lib/layerOrdering";
15
15
  import {
16
16
  furthestClipEndFromSource,
17
17
  getTimelineElementIdentity,
18
+ playbackStartAttributeForElement,
19
+ readPlaybackStartAttributes,
18
20
  } from "../player/lib/timelineElementHelpers";
21
+ import { resolveTimelinePlaybackRate } from "../player/components/timelineGroupEditing";
19
22
  import {
20
23
  saveProjectFilesWithHistory,
21
24
  writeProjectFilesWithHistoryInQueue,
@@ -199,13 +202,6 @@ export function removeIframeTimelineElements(
199
202
  findTimelineElementInIframe(iframe, element, activeCompositionPath)?.remove();
200
203
  }
201
204
 
202
- export function playbackStartAttributeForElement(
203
- element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
204
- ): "data-media-start" | "data-playback-start" {
205
- return element.playbackStartAttr === "playback-start" || element.kind === "composition"
206
- ? "data-playback-start"
207
- : "data-media-start";
208
- }
209
205
  // fallow-ignore-next-line complexity
210
206
  function resolveResizePlaybackStart(
211
207
  original: string,
@@ -219,15 +215,19 @@ function resolveResizePlaybackStart(
219
215
  }
220
216
  const trimDelta = updates.start - element.start;
221
217
  if (trimDelta === 0) return null;
222
- const raw =
223
- readAttributeByTarget(original, target, "playback-start") ??
224
- readAttributeByTarget(original, target, "media-start");
225
- const current = raw != null ? parseFloat(raw) : undefined;
226
- if (current == null || !Number.isFinite(current)) return null;
227
- const attrName = playbackStartAttributeForElement(element).slice("data-".length);
218
+ const source = readPlaybackStartAttributes((name) =>
219
+ readAttributeByTarget(original, target, name),
220
+ );
221
+ if (source.playbackStart == null) return null;
222
+ const attrName = playbackStartAttributeForElement({ kind: element.kind, ...source }).slice(
223
+ "data-".length,
224
+ );
228
225
  return {
229
226
  attrName,
230
- value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
227
+ value: Math.max(
228
+ 0,
229
+ source.playbackStart + trimDelta * resolveTimelinePlaybackRate(element.playbackRate),
230
+ ),
231
231
  };
232
232
  }
233
233
 
@@ -308,25 +308,26 @@ export interface PersistTimelineEditInput {
308
308
 
309
309
  export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
310
310
  const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
311
- const originalContent = await readFileContent(input.projectId, targetPath);
312
-
313
311
  const patchTarget = buildPatchTarget(input.element);
314
312
  if (!patchTarget) {
315
313
  throw new Error(`Timeline element ${input.element.id} is missing a patchable target`);
316
314
  }
317
315
 
318
- const patchedContent = input.buildPatches(originalContent, patchTarget);
319
- if (patchedContent === originalContent) {
320
- throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
321
- }
322
-
323
316
  input.pendingTimelineEditPathRef.current.add(targetPath);
324
317
  await saveProjectFilesWithHistory({
325
318
  projectId: input.projectId,
326
319
  label: input.label,
327
320
  coalesceKey: input.coalesceKey,
328
- files: { [targetPath]: patchedContent },
329
- readFile: async () => originalContent,
321
+ files: {
322
+ [targetPath]: (current) => {
323
+ const patched = input.buildPatches(current, patchTarget);
324
+ if (patched === current) {
325
+ throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
326
+ }
327
+ return patched;
328
+ },
329
+ },
330
+ readFile: (path) => readFileContent(input.projectId, path),
330
331
  writeFile: input.writeProjectFile,
331
332
  recordEdit: input.recordEdit,
332
333
  });
@@ -337,6 +338,36 @@ export interface PersistTimelineBatchChange {
337
338
  buildPatches: (original: string, target: PatchTarget) => string;
338
339
  }
339
340
 
341
+ /** One batch change per element, each applying the same patch operation. */
342
+ export function operationChanges(
343
+ elements: readonly TimelineElement[],
344
+ operation: PatchOperation,
345
+ ): PersistTimelineBatchChange[] {
346
+ return elements.map((element) => ({
347
+ element,
348
+ buildPatches: (html, target) => applyPatchByTarget(html, target, operation),
349
+ }));
350
+ }
351
+
352
+ /** Patches each change into `source`, failing loudly on a target the file does not hold. */
353
+ export function patchTimelineChangesInSource(
354
+ source: string,
355
+ targetPath: string,
356
+ changes: readonly PersistTimelineBatchChange[],
357
+ ): string {
358
+ let current = source;
359
+ for (const { element, buildPatches } of changes) {
360
+ const target = buildPatchTarget(element);
361
+ if (!target) throw new Error(`Timeline element ${element.id} is missing a patchable target`);
362
+ // Resolve first: a member already at its target values patches to the same string, a missing one must throw.
363
+ if (!findTagByTarget(current, target)) {
364
+ throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
365
+ }
366
+ current = buildPatches(current, target);
367
+ }
368
+ return current;
369
+ }
370
+
340
371
  export interface PersistTimelineBatchEditInput {
341
372
  projectId: string;
342
373
  activeCompPath: string | null;
@@ -353,50 +384,24 @@ export interface PersistTimelineBatchEditInput {
353
384
  export async function persistTimelineBatchEdit(
354
385
  input: PersistTimelineBatchEditInput,
355
386
  ): Promise<void> {
356
- const originals = new Map<string, string>();
357
- const patchedByPath = new Map<string, string>();
358
-
387
+ const changesByPath = new Map<string, PersistTimelineBatchChange[]>();
359
388
  for (const change of input.changes) {
360
389
  const targetPath = change.element.sourceFile || input.activeCompPath || "index.html";
361
- const original =
362
- originals.get(targetPath) ?? (await readFileContent(input.projectId, targetPath));
363
- originals.set(targetPath, original);
364
-
365
- const patchTarget = buildPatchTarget(change.element);
366
- if (!patchTarget) {
367
- throw new Error(`Timeline element ${change.element.id} is missing a patchable target`);
368
- }
369
-
370
- const current = patchedByPath.get(targetPath) ?? original;
371
- // Resolve the target FIRST: byte-identical output below is only a legit
372
- // no-op when the member actually resolved in the source. A mistargeted
373
- // member (stale id/selector) must fail loudly like the single-edit path,
374
- // not be silently dropped as "already at target".
375
- if (!findTagByTarget(current, patchTarget)) {
376
- throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
377
- }
378
- const patched = change.buildPatches(current, patchTarget);
379
- // The target resolved, so a member whose attributes already hold the target
380
- // values patches to the identical string — e.g. a track-insert renumber
381
- // where one clip's lane is already correct. That is a legitimate no-op:
382
- // skip it instead of aborting (and rolling back) the whole batch.
383
- if (patched === current) continue;
384
- patchedByPath.set(targetPath, patched);
390
+ changesByPath.set(targetPath, [...(changesByPath.get(targetPath) ?? []), change]);
385
391
  }
392
+ const buildFile = (targetPath: string) => (original: string) => {
393
+ const next = patchTimelineChangesInSource(original, targetPath, changesByPath.get(targetPath)!);
394
+ if (next !== original) input.pendingTimelineEditPathRef.current.add(targetPath);
395
+ return next;
396
+ };
386
397
 
387
- if (patchedByPath.size === 0) return;
388
-
389
- const files = Object.fromEntries(patchedByPath);
390
- for (const targetPath of Object.keys(files)) {
391
- input.pendingTimelineEditPathRef.current.add(targetPath);
392
- }
393
398
  await saveProjectFilesWithHistory({
394
399
  projectId: input.projectId,
395
400
  label: input.label,
396
401
  coalesceKey: input.coalesceKey,
397
402
  coalesceMs: input.coalesceMs,
398
- files,
399
- readFile: async (path) => originals.get(path) ?? readFileContent(input.projectId, path),
403
+ files: Object.fromEntries([...changesByPath.keys()].map((path) => [path, buildFile(path)])),
404
+ readFile: (path) => readFileContent(input.projectId, path),
400
405
  writeFile: input.writeProjectFile,
401
406
  recordEdit: input.recordEdit,
402
407
  });
@@ -427,7 +432,7 @@ export function readTargetAttribute(
427
432
  return readAttributeByTarget(html, patchTarget, attr) ?? null;
428
433
  }
429
434
 
430
- export { applyPatchByTarget, formatTimelineAttributeNumber, formatTimelineMediaOffset };
435
+ export { formatTimelineAttributeNumber, formatTimelineMediaOffset };
431
436
 
432
437
  export { patchDocumentRootDuration } from "./timelineEditingGsap";
433
438
 
@@ -490,8 +495,8 @@ export async function persistElementAttribute({
490
495
  return await writeProjectFilesWithHistoryInQueue({
491
496
  projectId,
492
497
  label,
493
- files: { [targetPath]: patched },
494
- readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
498
+ files: { [targetPath]: () => patched },
499
+ readFile: async () => before,
495
500
  writeFile: writeProjectFile,
496
501
  recordEdit,
497
502
  });
@@ -0,0 +1,195 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ buildTimelineMoveTimingPatch,
5
+ buildTimelineResizeTimingPatch,
6
+ persistElementAttribute,
7
+ persistTimelineBatchEdit,
8
+ persistTimelineEdit,
9
+ } from "./timelineEditingHelpers";
10
+ import type { TimelineElement } from "../player/store/playerStore";
11
+ import { finishClipTimingFallback } from "./timelineTimingSync";
12
+ import { applyColorGradingScopeUpdate } from "../components/studioColorGradingScope";
13
+
14
+ const SOURCE =
15
+ '<div id="root"><video id="a" class="clip" data-start="1" data-duration="5" data-track-index="0"></video>' +
16
+ '<video id="b" class="clip" data-start="2" data-duration="5" data-track-index="1"></video>' +
17
+ '<script>gsap.to("#a", { x: 1 }, 1)</script></div>';
18
+
19
+ // Refuses a write whose base is stale, like the server's If-Match; one write can be held mid-flight.
20
+ // A GSAP shift lands 10 ms after it is posted and releases the held write.
21
+ function fakeProject() {
22
+ let file = SOURCE;
23
+ const refused: string[] = [];
24
+ let writes = 0;
25
+ let release = () => {};
26
+ const firstWrite = new Promise<void>((resolve) => (release = resolve));
27
+ vi.stubGlobal(
28
+ "fetch",
29
+ vi.fn(async (input: string) => {
30
+ if (input.includes("/gsap-mutation-capabilities")) {
31
+ return Response.json({ atomicOwnershipPairs: true });
32
+ }
33
+ if (input.includes("/gsap-mutations/")) {
34
+ await new Promise((resolve) => setTimeout(resolve, 10));
35
+ const before = file;
36
+ file = file.replace("{ x: 1 }, 1)", "{ x: 1 }, 3)");
37
+ release();
38
+ return Response.json({ mutated: true, before, after: file, scriptText: null });
39
+ }
40
+ return Response.json({ content: file });
41
+ }),
42
+ );
43
+ const writeProjectFile = async (path: string, content: string, expected?: string) => {
44
+ if (++writes === 1) await firstWrite;
45
+ if (expected !== undefined && expected !== file && content !== file) {
46
+ refused.push(path);
47
+ throw new Error(`409 conflict on ${path}`);
48
+ }
49
+ file = content;
50
+ };
51
+ return { writeProjectFile, refused, release: () => release(), read: () => file };
52
+ }
53
+
54
+ type Project = ReturnType<typeof fakeProject>;
55
+
56
+ function clip(id: string): TimelineElement {
57
+ return { id, key: id, domId: id, tag: "video", label: id, start: 0, duration: 5, track: 0 };
58
+ }
59
+
60
+ const shared = (project: Project) => ({
61
+ projectId: "p1",
62
+ activeCompPath: "index.html",
63
+ writeProjectFile: project.writeProjectFile,
64
+ recordEdit: async () => {},
65
+ pendingTimelineEditPathRef: { current: new Set<string>() },
66
+ });
67
+
68
+ const move = (project: Project, id: string, start: number) =>
69
+ persistTimelineEdit({
70
+ ...shared(project),
71
+ element: clip(id),
72
+ label: "Move clip",
73
+ buildPatches: (original, target) => buildTimelineMoveTimingPatch(original, target, start, 5),
74
+ });
75
+
76
+ const trim = (project: Project, id: string, duration: number) =>
77
+ persistTimelineBatchEdit({
78
+ ...shared(project),
79
+ label: "Trim clip",
80
+ changes: [
81
+ {
82
+ element: clip(id),
83
+ buildPatches: (original, target) =>
84
+ buildTimelineResizeTimingPatch(original, target, clip(id), { start: 0, duration }),
85
+ },
86
+ ],
87
+ });
88
+
89
+ // Starts the second save while the first one's write is in flight; both must land unrefused.
90
+ async function expectBothLand(
91
+ project: Project,
92
+ first: () => Promise<unknown>,
93
+ second: () => Promise<unknown>,
94
+ ) {
95
+ const saves = [first()];
96
+ await new Promise((resolve) => setTimeout(resolve, 0));
97
+ saves.push(second());
98
+ await new Promise((resolve) => setTimeout(resolve, 0));
99
+ project.release();
100
+ const results = await Promise.allSettled(saves);
101
+ expect(project.refused).toEqual([]);
102
+ expect(results.map((r) => r.status)).toEqual(["fulfilled", "fulfilled"]);
103
+ }
104
+
105
+ afterEach(() => {
106
+ vi.unstubAllGlobals();
107
+ });
108
+
109
+ describe("two quick timeline saves on one file", () => {
110
+ it("lands both moves", async () => {
111
+ const project = fakeProject();
112
+ await expectBothLand(
113
+ project,
114
+ () => move(project, "a", 3),
115
+ () => move(project, "b", 4),
116
+ );
117
+
118
+ expect(project.read()).toContain('id="a" class="clip" data-start="3"');
119
+ expect(project.read()).toContain('id="b" class="clip" data-start="4"');
120
+ });
121
+
122
+ it("lands both trims", async () => {
123
+ const project = fakeProject();
124
+ await expectBothLand(
125
+ project,
126
+ () => trim(project, "a", 3),
127
+ () => trim(project, "b", 2),
128
+ );
129
+
130
+ expect(project.read()).toMatch(/id="a"[^>]*data-duration="3"/);
131
+ expect(project.read()).toMatch(/id="b"[^>]*data-duration="2"/);
132
+ });
133
+
134
+ it("lands a move started while a volume save is writing", async () => {
135
+ const project = fakeProject();
136
+ const volume = () =>
137
+ persistElementAttribute({
138
+ ...shared(project),
139
+ targetPath: "index.html",
140
+ patchTarget: { id: "a" },
141
+ attr: "data-volume",
142
+ value: "0.5",
143
+ label: "Set volume",
144
+ patchLive: () => {},
145
+ onFileRead: () => {},
146
+ });
147
+ await expectBothLand(project, volume, () => move(project, "b", 4));
148
+
149
+ expect(project.read()).toContain('data-volume="0.5"');
150
+ expect(project.read()).toContain('id="b" class="clip" data-start="4"');
151
+ });
152
+
153
+ it("keeps a move when a color grade starts while it is writing", async () => {
154
+ const project = fakeProject();
155
+ const grade = () =>
156
+ applyColorGradingScopeUpdate({
157
+ scope: "source-file",
158
+ value: "warm",
159
+ selectedSourceFile: "index.html",
160
+ fileTree: ["index.html"],
161
+ projectId: "p1",
162
+ waitForPendingDomEditSaves: async () => {},
163
+ readProjectFile: async () => project.read(),
164
+ writeProjectFile: project.writeProjectFile,
165
+ recordEdit: async () => {},
166
+ reloadPreview: () => {},
167
+ showToast: () => {},
168
+ });
169
+ await expectBothLand(project, () => move(project, "a", 3), grade);
170
+
171
+ expect(project.read()).toContain('id="a" class="clip" data-start="3"');
172
+ expect(project.read()).toContain('data-color-grading="warm"');
173
+ });
174
+
175
+ it("lands a move started while the previous move's GSAP rewrite is in flight", async () => {
176
+ const project = fakeProject();
177
+ const shiftA = finishClipTimingFallback({
178
+ iframe: null,
179
+ reloadPreview: () => {},
180
+ projectId: "p1",
181
+ targetPath: "index.html",
182
+ domId: "a",
183
+ label: "Move clip",
184
+ recordEdit: async () => {},
185
+ writeProjectFile: project.writeProjectFile,
186
+ edit: { kind: "shift", delta: 2 },
187
+ });
188
+ const results = await Promise.allSettled([shiftA, move(project, "b", 4)]);
189
+
190
+ expect(project.refused).toEqual([]);
191
+ expect(results.map((r) => r.status)).toEqual(["fulfilled", "fulfilled"]);
192
+ expect(project.read()).toContain('gsap.to("#a", { x: 1 }, 3)');
193
+ expect(project.read()).toContain('id="b" class="clip" data-start="4"');
194
+ });
195
+ });
@@ -11,6 +11,8 @@ import {
11
11
  shiftGsapPositions,
12
12
  } from "./timelineTimingSync";
13
13
 
14
+ const writeProjectFile = async () => {};
15
+
14
16
  afterEach(() => {
15
17
  usePlayerStore.getState().reset();
16
18
  vi.restoreAllMocks();
@@ -68,6 +70,7 @@ function clipFallbackInput(overrides: {
68
70
  domId: "clip",
69
71
  label: "Move timeline clip",
70
72
  recordEdit: overrides.recordEdit as never,
73
+ writeProjectFile,
71
74
  edit: { kind: "shift", delta: 1 } as const,
72
75
  };
73
76
  }
@@ -495,6 +498,7 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
495
498
  const element = { sourceFile: "index.html" } as TimelineElement;
496
499
 
497
500
  await finishGroupTimingGsapFallback({
501
+ writeProjectFile,
498
502
  projectId: "p1",
499
503
  iframe,
500
504
  reloadPreview,
@@ -522,6 +526,7 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
522
526
  const reloadPreview = vi.fn();
523
527
 
524
528
  await finishGroupTimingGsapFallback({
529
+ writeProjectFile,
525
530
  projectId: "p1",
526
531
  iframe,
527
532
  reloadPreview,
@@ -602,6 +607,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
602
607
  let clipIndex = 0;
603
608
 
604
609
  await finishGroupTimingGsapFallback({
610
+ writeProjectFile,
605
611
  projectId: "p1",
606
612
  iframe: buildLivePreviewIframe().iframe,
607
613
  reloadPreview: vi.fn(),
@@ -632,6 +638,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
632
638
  let clipIndex = 0;
633
639
 
634
640
  await finishGroupTimingGsapFallback({
641
+ writeProjectFile,
635
642
  projectId: "p1",
636
643
  iframe: buildLivePreviewIframe().iframe,
637
644
  reloadPreview: vi.fn(),
@@ -669,6 +676,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
669
676
 
670
677
  const reloadPreview = vi.fn();
671
678
  await finishGroupTimingGsapFallback({
679
+ writeProjectFile,
672
680
  projectId: "p1",
673
681
  iframe: buildLivePreviewIframe().iframe,
674
682
  reloadPreview,
@@ -698,6 +706,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
698
706
  let clipIndex = 0;
699
707
 
700
708
  await finishGroupTimingGsapFallback({
709
+ writeProjectFile,
701
710
  projectId: "p1",
702
711
  iframe: buildLivePreviewIframe().iframe,
703
712
  reloadPreview,
@@ -728,6 +737,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
728
737
  const recordEdit = vi.fn(async () => {});
729
738
 
730
739
  await finishGroupTimingGsapFallback({
740
+ writeProjectFile,
731
741
  projectId: "p1",
732
742
  iframe: buildLivePreviewIframe().iframe,
733
743
  reloadPreview: vi.fn(),
@@ -762,6 +772,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
762
772
 
763
773
  const reloadPreview = vi.fn();
764
774
  await finishGroupTimingGsapFallback({
775
+ writeProjectFile,
765
776
  projectId: "p1",
766
777
  iframe: buildLivePreviewIframe().iframe,
767
778
  reloadPreview,
@@ -797,6 +808,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
797
808
  const server = installOwnedFileServer(contents, { failReadAt: 1 });
798
809
 
799
810
  await finishGroupTimingGsapFallback({
811
+ writeProjectFile,
800
812
  projectId: "p1",
801
813
  iframe: buildLivePreviewIframe().iframe,
802
814
  reloadPreview: vi.fn(),