@hyperframes/studio 0.8.113 → 0.8.115

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 (194) hide show
  1. package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
  4. package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/index-t1LH6H5H.css +1 -0
  6. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  7. package/dist/chunk-5EKWJZLM.js.map +1 -0
  8. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  9. package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
  10. package/dist/chunk-TXZMMOMA.js.map +1 -0
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
  12. package/dist/index.d.ts +46 -22
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +3899 -3487
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +10 -8
  19. package/src/App.tsx +84 -86
  20. package/src/components/EditorShell.tsx +3 -3
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/StudioHeader.tsx +4 -4
  23. package/src/components/StudioLeftPanels.tsx +3 -3
  24. package/src/components/StudioOverlays.tsx +4 -4
  25. package/src/components/StudioRightPanels.tsx +14 -9
  26. package/src/components/StudioToast.tsx +1 -1
  27. package/src/components/TimelineToolbar.tsx +3 -3
  28. package/src/components/dock/Dock.tsx +5 -7
  29. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  30. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  31. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  32. package/src/components/editor/DomEditOverlay.tsx +5 -1
  33. package/src/components/editor/FileTreeNodes.tsx +13 -10
  34. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  35. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  37. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  38. package/src/components/editor/SnapToolbar.tsx +43 -5
  39. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  40. package/src/components/editor/audioFxTelemetry.ts +2 -3
  41. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  42. package/src/components/editor/colorValue.test.ts +16 -0
  43. package/src/components/editor/colorValue.ts +100 -24
  44. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  45. package/src/components/editor/cropResize.test.ts +11 -0
  46. package/src/components/editor/cropResize.ts +6 -6
  47. package/src/components/editor/domEditingDom.ts +4 -0
  48. package/src/components/editor/domEditingLayers.ts +1 -5
  49. package/src/components/editor/gradientValue.ts +28 -36
  50. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  51. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  52. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  53. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  54. package/src/components/editor/manualEdits.ts +1 -0
  55. package/src/components/editor/manualEditsDom.ts +2 -2
  56. package/src/components/editor/manualEditsParsing.ts +7 -0
  57. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  58. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  59. package/src/components/editor/marqueeCommit.ts +24 -8
  60. package/src/components/editor/motionPathCommit.test.ts +29 -1
  61. package/src/components/editor/motionPathCommit.ts +21 -9
  62. package/src/components/editor/probeSourceElement.test.ts +45 -0
  63. package/src/components/editor/probeSourceElement.ts +39 -15
  64. package/src/components/editor/propertyPanelColor.tsx +1 -1
  65. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  66. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  67. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  68. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  69. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  70. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  71. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  72. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  73. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  74. package/src/components/editor/propertyPanelTypes.ts +6 -3
  75. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  76. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  77. package/src/components/editor/useDomEditNudge.ts +2 -0
  78. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  79. package/src/components/editor/useDomEditZOrder.ts +12 -2
  80. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  81. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  82. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  83. package/src/components/ui/menuStyle.test.ts +61 -0
  84. package/src/components/ui/menuStyle.ts +10 -0
  85. package/src/contexts/DomEditContext.tsx +1 -1
  86. package/src/contexts/TimelineEditContext.tsx +1 -0
  87. package/src/domEditExports.test.tsx +9 -1
  88. package/src/hooks/domEditCommitRunner.ts +1 -1
  89. package/src/hooks/elementOffsetStager.ts +5 -4
  90. package/src/hooks/gsapDragCommit.ts +2 -20
  91. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  92. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  93. package/src/hooks/gsapGestureOutcome.ts +64 -0
  94. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  95. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  96. package/src/hooks/gsapResizeIntercept.ts +1 -0
  97. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  98. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  99. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  100. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  101. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  102. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  103. package/src/hooks/plainRotation.ts +3 -2
  104. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  105. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  106. package/src/hooks/refreshTimelineRowText.ts +29 -0
  107. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  108. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  109. package/src/hooks/useAppHotkeys.ts +11 -5
  110. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  112. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  113. package/src/hooks/useDomEditTextCommits.ts +5 -1
  114. package/src/hooks/useDomEditWiring.ts +4 -2
  115. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  116. package/src/hooks/useDomGeometryCommit.ts +7 -4
  117. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  118. package/src/hooks/useDomGeometryCommits.ts +3 -2
  119. package/src/hooks/useDomSelection.test.ts +26 -0
  120. package/src/hooks/useDomSelection.ts +10 -3
  121. package/src/hooks/useDomSelectionTypes.ts +6 -1
  122. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  123. package/src/hooks/useEnableKeyframes.ts +5 -3
  124. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  125. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  126. package/src/hooks/useFrameCapture.ts +15 -8
  127. package/src/hooks/useGestureCommit.test.tsx +225 -55
  128. package/src/hooks/useGestureCommit.ts +90 -56
  129. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  130. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  131. package/src/hooks/useGsapAwareEditing.ts +96 -195
  132. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  133. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  134. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  135. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  136. package/src/hooks/useGsapScriptCommits.ts +3 -0
  137. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  138. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  139. package/src/hooks/useGsapTweenCache.ts +18 -12
  140. package/src/hooks/usePanelLayout.ts +5 -1
  141. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  142. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  143. package/src/hooks/usePreviewInteraction.ts +2 -0
  144. package/src/hooks/useRenderClipContent.test.ts +18 -0
  145. package/src/hooks/useRenderClipContent.ts +4 -5
  146. package/src/hooks/useShellSlices.ts +53 -0
  147. package/src/hooks/useStableHandlers.test.tsx +77 -0
  148. package/src/hooks/useStableHandlers.ts +44 -0
  149. package/src/hooks/useStudioSdkSessions.ts +2 -1
  150. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  151. package/src/hooks/useStudioSidePanels.tsx +23 -0
  152. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  153. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  154. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  155. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  156. package/src/hooks/useTimelineEditing.ts +33 -27
  157. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  158. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  159. package/src/player/components/ClipContextMenu.tsx +33 -31
  160. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  161. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  162. package/src/player/components/TextClipContent.test.ts +19 -0
  163. package/src/player/components/TextClipContent.tsx +27 -0
  164. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  165. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  166. package/src/player/components/clipMenuToolItems.tsx +13 -9
  167. package/src/player/components/timelineCallbacks.ts +1 -0
  168. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  169. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  170. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  171. package/src/player/components/timelineEditing.ts +3 -1
  172. package/src/player/components/timelineLinkSelection.ts +35 -1
  173. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  174. package/src/player/components/useTimelineClipDrag.ts +6 -7
  175. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  176. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  177. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  178. package/src/player/lib/timelineElementHelpers.ts +6 -15
  179. package/src/player/lib/timelineText.ts +40 -0
  180. package/src/player/store/timelineElement.ts +12 -0
  181. package/src/styles/theme.css +3 -0
  182. package/src/utils/frameCapture.ts +3 -1
  183. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  184. package/src/utils/keyframeUsage.ts +79 -0
  185. package/src/utils/previewFeatureUsage.test.ts +32 -0
  186. package/src/utils/previewFeatureUsage.ts +46 -0
  187. package/src/utils/studioTelemetry.test.ts +107 -0
  188. package/src/utils/studioTelemetry.ts +49 -4
  189. package/src/webmcp/StudioAgentTools.tsx +2 -2
  190. package/dist/assets/index-DbfKVTId.css +0 -1
  191. package/dist/chunk-BIUXP3PG.js.map +0 -1
  192. package/dist/chunk-K47E2ZJA.js.map +0 -1
  193. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  194. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -334,3 +334,29 @@ describe("useDomSelection", () => {
334
334
  harness.cleanup();
335
335
  });
336
336
  });
337
+
338
+ it("reports unchanged marquee membership and one new additive member", () => {
339
+ const { harness, card, chip } = renderCardAndChip();
340
+ try {
341
+ act(() => harness.current().applyDomSelection(card));
342
+ let receipt!: ReturnType<ReturnType<typeof useDomSelection>["applyMarqueeSelection"]>;
343
+ act(() => {
344
+ receipt = harness.current().applyMarqueeSelection([chip], true);
345
+ });
346
+ expect(receipt).toEqual({ changed: true, count: 2 });
347
+ act(() => {
348
+ receipt = harness.current().applyMarqueeSelection([card, chip], true);
349
+ });
350
+ expect(receipt).toEqual({ changed: false, count: 2 });
351
+ act(() => {
352
+ receipt = harness.current().applyMarqueeSelection([], true);
353
+ });
354
+ expect(receipt).toEqual({ changed: false, count: 2 });
355
+ act(() => {
356
+ receipt = harness.current().applyMarqueeSelection([], false);
357
+ });
358
+ expect(receipt).toEqual({ changed: true, count: 0 });
359
+ } finally {
360
+ harness.cleanup();
361
+ }
362
+ });
@@ -487,12 +487,13 @@ export function useDomSelection({
487
487
  // fallow-ignore-next-line complexity
488
488
  (selections: DomEditSelection[], additive: boolean) => {
489
489
  logSelect("marquee", { hits: selections.length, additive });
490
+ const current = domEditSelectionRef.current;
491
+ const currentGroup = domEditGroupSelectionsRef.current;
490
492
  if (selections.length === 0) {
493
+ const count = seedDomEditGroupWithSelection(currentGroup, current).length;
491
494
  if (!additive) applyDomSelection(null, { revealPanel: false });
492
- return;
495
+ return { changed: !additive && count > 0, count: additive ? count : 0 };
493
496
  }
494
- const current = domEditSelectionRef.current;
495
- const currentGroup = domEditGroupSelectionsRef.current;
496
497
  let nextGroup: DomEditSelection[];
497
498
  if (additive) {
498
499
  nextGroup = seedDomEditGroupWithSelection(currentGroup, current);
@@ -507,11 +508,17 @@ export function useDomSelection({
507
508
  }
508
509
  }
509
510
  const nextSelection = additive && current ? current : selections[0];
511
+ const previous = seedDomEditGroupWithSelection(currentGroup, current);
512
+ const changed =
513
+ previous.length !== nextGroup.length ||
514
+ nextGroup.some((selection) => !domEditSelectionInGroup(previous, selection)) ||
515
+ !domEditSelectionsTargetSame(current, nextSelection);
510
516
  domEditSelectionRef.current = nextSelection;
511
517
  domEditGroupSelectionsRef.current = nextGroup;
512
518
  setDomEditSelection(nextSelection);
513
519
  setDomEditGroupSelections(nextGroup);
514
520
  announceTimelineSelection(nextGroup, nextSelection, true);
521
+ return { changed, count: nextGroup.length };
515
522
  },
516
523
  [applyDomSelection, announceTimelineSelection],
517
524
  );
@@ -3,6 +3,11 @@ import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import type { SelectElementOptions, TimelineElement } from "../player";
4
4
  import type { RightPanelTab } from "../utils/studioHelpers";
5
5
 
6
+ export interface DomSelectionResult {
7
+ changed: boolean;
8
+ count: number;
9
+ }
10
+
6
11
  export interface ApplyDomSelectionOptions {
7
12
  revealPanel?: boolean;
8
13
  additive?: boolean;
@@ -85,5 +90,5 @@ export interface UseDomSelectionReturn {
85
90
  handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
86
91
  refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
87
92
  refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
88
- applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
93
+ applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => DomSelectionResult;
89
94
  }
@@ -314,7 +314,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
314
314
  ],
315
315
  ease: "none",
316
316
  },
317
- { label: "Add keyframe", softReload: true },
317
+ { label: "Add keyframe", softReload: true, keyframeAction: "add" },
318
318
  );
319
319
  });
320
320
 
@@ -336,7 +336,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
336
336
  duration: 10,
337
337
  keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]),
338
338
  }),
339
- { label: "Add keyframe", softReload: true },
339
+ { label: "Add keyframe", softReload: true, keyframeAction: "add" },
340
340
  );
341
341
  });
342
342
  });
@@ -159,7 +159,7 @@ async function replaceSetWithSingleKeyframe(
159
159
  keyframes: [{ percentage: 0, properties: position }],
160
160
  ease: setAnim.ease,
161
161
  },
162
- { label: "Enable keyframes", softReload: true },
162
+ { label: "Enable keyframes", keyframeAction: "add", softReload: true },
163
163
  );
164
164
  }
165
165
 
@@ -270,6 +270,7 @@ async function extendKeyframedTweenToPlayhead(
270
270
  },
271
271
  {
272
272
  label: "Add keyframe",
273
+ keyframeAction: "add",
273
274
  softReload: true,
274
275
  ...commitOverrides,
275
276
  },
@@ -377,7 +378,7 @@ export async function promoteSetToKeyframes(
377
378
  ],
378
379
  ease: setAnim.ease,
379
380
  },
380
- { label: "Add keyframe", softReload: true },
381
+ { label: "Add keyframe", keyframeAction: "add", softReload: true },
381
382
  );
382
383
  }
383
384
 
@@ -454,7 +455,7 @@ export async function applyArcKeyframeAtPlayhead(
454
455
  }),
455
456
  ease: "none",
456
457
  },
457
- { label: "Add keyframe", softReload: true },
458
+ { label: "Add keyframe", keyframeAction: "add", softReload: true },
458
459
  );
459
460
  }
460
461
 
@@ -503,6 +504,7 @@ export function useEnableKeyframes(
503
504
  enableKeyframesTransactionCounter += 1;
504
505
  const coalesceKey = `enable-keyframes:${flatAnim.id}:${enableKeyframesTransactionCounter}`;
505
506
  const convertCommitOverrides: Partial<CommitMutationOptions> = {
507
+ keyframeTelemetry: false,
506
508
  skipReload: true,
507
509
  coalesceKey,
508
510
  coalesceMs: Number.POSITIVE_INFINITY,
@@ -143,6 +143,85 @@ describe("external file change coordinator", () => {
143
143
  expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("scene.html", ["scene.html"]);
144
144
  });
145
145
 
146
+ it("keeps a waiting head edit's thumbnail refresh when a later write replaces it", async () => {
147
+ let release = () => {};
148
+ const inFlight = new Promise<void>((resolve) => (release = resolve));
149
+ const onAcceptedPersistedFileChange = vi.fn();
150
+ let drains = 0;
151
+ await mountCoordinator({
152
+ onAcceptedPersistedFileChange,
153
+ drainPendingChanges: async () => {
154
+ if (drains++ === 0) await inFlight;
155
+ return { status: "clean" as const };
156
+ },
157
+ });
158
+ await act(async () => handler?.({ path: "other.html", content: "a", version: "v1" }));
159
+ await act(async () => handler?.({ path: "index.html", content: "b", version: "v2" }));
160
+ await act(async () =>
161
+ handler?.({
162
+ path: "index.html",
163
+ content: "c",
164
+ version: "v3",
165
+ affectedCompositions: ["index.html"],
166
+ }),
167
+ );
168
+ await act(async () => release());
169
+ expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
170
+ });
171
+
172
+ it("keeps a head edit's thumbnail refresh through a conflict that a later write replaces", async () => {
173
+ const onAcceptedPersistedFileChange = vi.fn();
174
+ const conflict = new StudioFileConflictError({
175
+ filePath: "index.html",
176
+ currentVersion: "v1",
177
+ currentContent: "theirs",
178
+ attemptedContent: "mine",
179
+ });
180
+ const { captured } = await mountCoordinator({
181
+ onAcceptedPersistedFileChange,
182
+ drainPendingChanges: async () => ({ status: "conflict" as const, error: conflict }),
183
+ });
184
+ await act(async () => handler?.({ path: "index.html", content: "head", version: "v1" }));
185
+ await act(async () =>
186
+ handler?.({
187
+ path: "index.html",
188
+ content: "body",
189
+ version: "v2",
190
+ affectedCompositions: ["index.html"],
191
+ }),
192
+ );
193
+ await act(async () => captured.handle?.useExternalFile());
194
+ expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
195
+ });
196
+
197
+ it("keeps a held head edit's thumbnail refresh when a later write saves cleanly", async () => {
198
+ const onAcceptedPersistedFileChange = vi.fn();
199
+ const conflict = new StudioFileConflictError({
200
+ filePath: "index.html",
201
+ currentVersion: "v1",
202
+ currentContent: "theirs",
203
+ attemptedContent: "mine",
204
+ });
205
+ let drains = 0;
206
+ await mountCoordinator({
207
+ onAcceptedPersistedFileChange,
208
+ drainPendingChanges: async () =>
209
+ drains++ === 0
210
+ ? { status: "conflict" as const, error: conflict }
211
+ : { status: "clean" as const },
212
+ });
213
+ await act(async () => handler?.({ path: "index.html", content: "head", version: "v1" }));
214
+ await act(async () =>
215
+ handler?.({
216
+ path: "index.html",
217
+ content: "body",
218
+ version: "v2",
219
+ affectedCompositions: ["index.html"],
220
+ }),
221
+ );
222
+ expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
223
+ });
224
+
146
225
  it("does not refresh the tree for a suppressed self-write echo", async () => {
147
226
  const refreshFileTree = vi.fn();
148
227
  await mountCoordinator({ refreshFileTree });
@@ -333,6 +412,29 @@ describe("external file change coordinator", () => {
333
412
  expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
334
413
  });
335
414
 
415
+ it("keeps the failed file's payload when a snapshot delete fails for another file", async () => {
416
+ const { captured } = await mountCoordinator({
417
+ drainPendingChanges: vi
418
+ .fn()
419
+ .mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") })
420
+ .mockResolvedValueOnce({ status: "clean" as const }),
421
+ getPendingCandidate: () => ({ path: "scene.html", content: "studio" }),
422
+ deleteConflictSnapshot: vi.fn(async () => {
423
+ throw new Error("delete failed");
424
+ }),
425
+ });
426
+ await act(async () =>
427
+ handler?.({ path: "scene.html", content: "scene-external", version: "s1" }),
428
+ );
429
+ await act(async () =>
430
+ handler?.({ path: "index.html", content: "index-external", version: "i1" }),
431
+ );
432
+ expect(captured.handle?.blocked).toMatchObject({
433
+ status: "failed",
434
+ payload: { path: "scene.html", content: "scene-external", version: "s1" },
435
+ });
436
+ });
437
+
336
438
  it("restores and overwrites from a durable failed draft", async () => {
337
439
  const overwriteConflict = vi.fn(async () => undefined);
338
440
  const onAcceptedPersistedFileChange = vi.fn();
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
2
2
  import {
3
+ mergeFileChangeAffectedCompositions,
3
4
  readFileChangeAffectedCompositions,
4
5
  readFileChangeAffectsPreview,
5
6
  readFileChangeField,
@@ -163,7 +164,7 @@ export function useExternalFileChangeCoordinator({
163
164
  onAcceptedPersistedFileChange,
164
165
  refreshFileTree,
165
166
  }: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
166
- const [blocked, setBlocked] = useState<ExternalFileChangeBlockedState | null>(null);
167
+ const [blocked, setBlockedState] = useState<ExternalFileChangeBlockedState | null>(null);
167
168
  const generationRef = useRef(0);
168
169
  const mountedRef = useRef(true);
169
170
  const lastEventIdentityRef = useRef<string | null>(null);
@@ -172,6 +173,14 @@ export function useExternalFileChangeCoordinator({
172
173
  const drainingRef = useRef(false);
173
174
  const pendingPayloadRef = useRef<{ payload: unknown } | null>(null);
174
175
  blockedRef.current = blocked;
176
+ // A newer blocked change replaces the held one, so it inherits the thumbnails the held one owed.
177
+ const setBlocked = useCallback((next: ExternalFileChangeBlockedState | null) => {
178
+ setBlockedState((held) =>
179
+ next && held
180
+ ? { ...next, payload: mergeFileChangeAffectedCompositions(held.payload, next.payload) }
181
+ : next,
182
+ );
183
+ }, []);
175
184
 
176
185
  useEffect(() => {
177
186
  mountedRef.current = true;
@@ -185,7 +194,7 @@ export function useExternalFileChangeCoordinator({
185
194
  generationRef.current += 1;
186
195
  setBlocked(null);
187
196
  lastEventIdentityRef.current = null;
188
- }, [projectId, activeCompPath]);
197
+ }, [projectId, activeCompPath, setBlocked]);
189
198
 
190
199
  useEffect(() => {
191
200
  if (!projectId || !recoveryFilePath || !loadConflictSnapshot) return;
@@ -229,7 +238,7 @@ export function useExternalFileChangeCoordinator({
229
238
  return () => {
230
239
  cancelled = true;
231
240
  };
232
- }, [loadConflictSnapshot, projectId, recoveryFilePath]);
241
+ }, [loadConflictSnapshot, projectId, recoveryFilePath, setBlocked]);
233
242
 
234
243
  const reloadAcceptedGeneration = useCallback(
235
244
  (path: string, affectsPreview = true) => {
@@ -275,14 +284,22 @@ export function useExternalFileChangeCoordinator({
275
284
  await deleteConflictSnapshot(projectId!, path);
276
285
  } catch (error) {
277
286
  if (mountedRef.current && generation === generationRef.current) {
278
- setBlocked({ ...previousBlocked, generation, error });
287
+ setBlocked({
288
+ ...previousBlocked,
289
+ generation,
290
+ error,
291
+ payload: mergeFileChangeAffectedCompositions(payload, previousBlocked.payload),
292
+ });
279
293
  }
280
294
  return;
281
295
  }
282
296
  }
283
297
  if (!mountedRef.current || generation !== generationRef.current) return;
284
298
  setBlocked(null);
285
- onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(payload));
299
+ const owed = previousBlocked
300
+ ? mergeFileChangeAffectedCompositions(previousBlocked.payload, payload)
301
+ : payload;
302
+ onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(owed));
286
303
  reloadAcceptedGeneration(path, readFileChangeAffectsPreview(payload));
287
304
  return;
288
305
  }
@@ -344,6 +361,7 @@ export function useExternalFileChangeCoordinator({
344
361
  },
345
362
  [
346
363
  drainPendingChanges,
364
+ setBlocked,
347
365
  projectId,
348
366
  deleteConflictSnapshot,
349
367
  getPendingCandidate,
@@ -419,7 +437,12 @@ export function useExternalFileChangeCoordinator({
419
437
  waiting != null &&
420
438
  readFileChangeAffectsPreview(waiting) &&
421
439
  !readFileChangeAffectsPreview(payload);
422
- if (!waitingChangeOutranksThis) pendingPayloadRef.current = { payload };
440
+ if (!waitingChangeOutranksThis) {
441
+ pendingPayloadRef.current = {
442
+ payload:
443
+ waiting == null ? payload : mergeFileChangeAffectedCompositions(waiting, payload),
444
+ };
445
+ }
423
446
  void startDrainLoop();
424
447
  },
425
448
  [projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
@@ -469,6 +492,7 @@ export function useExternalFileChangeCoordinator({
469
492
  },
470
493
  [
471
494
  deleteConflictSnapshot,
495
+ setBlocked,
472
496
  discardPendingChanges,
473
497
  onUseExternalFile,
474
498
  onAcceptedPersistedFileChange,
@@ -527,6 +551,7 @@ export function useExternalFileChangeCoordinator({
527
551
  reloadAcceptedGeneration(conflict.filePath);
528
552
  }, [
529
553
  deleteConflictSnapshot,
554
+ setBlocked,
530
555
  discardPendingChanges,
531
556
  overwriteConflict,
532
557
  projectId,
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef, type MouseEvent } from "react";
1
+ import { useMemo, useState, useCallback, useRef, type MouseEvent } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import { liveTime, usePlayerStore } from "../player";
4
4
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
@@ -34,8 +34,10 @@ export function useFrameCapture({
34
34
  return isPlaying ? livePlayheadRef.current : currentTime;
35
35
  }, []);
36
36
 
37
+ const [captureStamp, setCaptureStamp] = useState(Date.now);
37
38
  const refreshCaptureFrameTime = useCallback(() => {
38
39
  setCaptureFrameTime(playheadTime());
40
+ setCaptureStamp(Date.now());
39
41
  }, [playheadTime]);
40
42
 
41
43
  const handleCaptureFrameClick = useCallback(
@@ -103,13 +105,18 @@ export function useFrameCapture({
103
105
  [activeCompPath, playheadTime, projectId, showToast, waitForPendingDomEditSaves],
104
106
  );
105
107
 
106
- const captureFrameHref = projectId
107
- ? buildFrameCaptureUrl({
108
- projectId,
109
- compositionPath: activeCompPath,
110
- currentTime: captureFrameTime,
111
- })
112
- : "#";
108
+ const captureFrameHref = useMemo(
109
+ () =>
110
+ projectId
111
+ ? buildFrameCaptureUrl({
112
+ projectId,
113
+ compositionPath: activeCompPath,
114
+ currentTime: captureFrameTime,
115
+ version: captureStamp,
116
+ })
117
+ : "#",
118
+ [projectId, activeCompPath, captureFrameTime, captureStamp],
119
+ );
113
120
  const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
114
121
 
115
122
  return {