@hyperframes/studio 0.7.90 → 0.7.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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -7,9 +7,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
8
 
9
9
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
+ import type { KeyframeCacheEntry } from "../player/store/playerStore";
11
+ import { usePlayerStore } from "../player/store/playerStore";
10
12
  import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
11
13
 
12
14
  type HookApi = ReturnType<typeof useGsapKeyframeOps>;
15
+ type CommitResult = { ok: boolean; changed: boolean };
16
+ type CommitOptions = { onResult?: (result: CommitResult) => void };
17
+ type CommitCall = [selection: unknown, mutation: unknown, options: CommitOptions];
18
+
19
+ function readCommitOptions(args: unknown[]): CommitOptions {
20
+ // The hook accepts the production writer type; these test doubles expose only
21
+ // the third tuple member they exercise.
22
+ return (args as unknown as CommitCall)[2];
23
+ }
13
24
 
14
25
  let cleanup: (() => void) | null = null;
15
26
  afterEach(() => {
@@ -21,15 +32,14 @@ const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSe
21
32
 
22
33
  function successfulCommitMutation() {
23
34
  return vi.fn<(...args: unknown[]) => Promise<unknown>>(async (...args) => {
24
- const options = args[2] as {
25
- onResult?: (result: { ok: boolean; changed: boolean }) => void;
26
- };
35
+ const options = readCommitOptions(args);
27
36
  options.onResult?.({ ok: true, changed: true });
28
37
  });
29
38
  }
30
39
 
31
40
  function renderKeyframeOps(over: {
32
41
  commitMutation: (...args: unknown[]) => Promise<unknown>;
42
+ commitMutationSafely?: (...args: unknown[]) => Promise<void>;
33
43
  trackGsapSaveFailure: (...args: unknown[]) => void;
34
44
  }) {
35
45
  const captured: { api: HookApi | null } = { api: null };
@@ -41,7 +51,7 @@ function renderKeyframeOps(over: {
41
51
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
42
52
  commitMutation: over.commitMutation as any,
43
53
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
44
- commitMutationSafely: (() => {}) as any,
54
+ commitMutationSafely: (over.commitMutationSafely ?? (async () => {})) as any,
45
55
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
46
56
  trackGsapSaveFailure: over.trackGsapSaveFailure as any,
47
57
  sdkSession: null,
@@ -135,7 +145,7 @@ describe("useGsapKeyframeOps — moveKeyframe settlement", () => {
135
145
 
136
146
  it("returns false when the writer accepts but does not change the keyframe", async () => {
137
147
  const commitMutation = vi.fn(async (...args: unknown[]) => {
138
- const options = args[2] as { onResult?: (result: { ok: boolean; changed: boolean }) => void };
148
+ const options = readCommitOptions(args);
139
149
  options.onResult?.({ ok: true, changed: false });
140
150
  });
141
151
  const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
@@ -203,6 +213,74 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => {
203
213
  );
204
214
  });
205
215
 
216
+ it("clears delete-all cache only when persistence confirms the change", async () => {
217
+ let finishCommit: (() => void) | undefined;
218
+ const commitMutationSafely = vi.fn(
219
+ (...args: unknown[]) =>
220
+ new Promise<void>((resolve) => {
221
+ const options = readCommitOptions(args);
222
+ finishCommit = () => {
223
+ options.onResult?.({ ok: true, changed: true });
224
+ resolve();
225
+ };
226
+ }),
227
+ );
228
+ const api = renderKeyframeOps({
229
+ commitMutation: successfulCommitMutation(),
230
+ commitMutationSafely,
231
+ trackGsapSaveFailure: vi.fn(),
232
+ });
233
+ const cached: KeyframeCacheEntry = {
234
+ format: "percentage",
235
+ keyframes: [
236
+ { percentage: 0, properties: { x: 0 } },
237
+ { percentage: 100, properties: { x: 200 } },
238
+ ],
239
+ };
240
+ usePlayerStore.setState({ keyframeCache: new Map([["index.html#box", cached]]) });
241
+
242
+ const pending = api.removeAllKeyframes(selection, "box-to-0-position");
243
+ expect(commitMutationSafely).toHaveBeenCalledWith(
244
+ selection,
245
+ { type: "remove-all-keyframes", animationId: "box-to-0-position" },
246
+ expect.objectContaining({ label: "Remove all keyframes", softReload: true }),
247
+ );
248
+ expect(usePlayerStore.getState().keyframeCache.get("index.html#box")).toBe(cached);
249
+
250
+ finishCommit?.();
251
+ await pending;
252
+ expect(usePlayerStore.getState().keyframeCache.has("index.html#box")).toBe(false);
253
+ });
254
+
255
+ it("clears the live DOM identity for a selector-only selection", async () => {
256
+ const element = document.createElement("div");
257
+ element.id = "box";
258
+ const selectorOnlySelection: DomEditSelection = {
259
+ ...selection,
260
+ id: undefined,
261
+ selector: ".box",
262
+ element,
263
+ };
264
+ const commitMutationSafely = vi.fn(async (...args: unknown[]) => {
265
+ const options = readCommitOptions(args);
266
+ options.onResult?.({ ok: true, changed: true });
267
+ });
268
+ const api = renderKeyframeOps({
269
+ commitMutation: successfulCommitMutation(),
270
+ commitMutationSafely,
271
+ trackGsapSaveFailure: vi.fn(),
272
+ });
273
+ const cached: KeyframeCacheEntry = {
274
+ format: "percentage",
275
+ keyframes: [{ percentage: 0, properties: { x: 0 } }],
276
+ };
277
+ usePlayerStore.setState({ keyframeCache: new Map([["index.html#box", cached]]) });
278
+
279
+ await api.removeAllKeyframes(selectorOnlySelection, "box-to-0-position");
280
+
281
+ expect(usePlayerStore.getState().keyframeCache.has("index.html#box")).toBe(false);
282
+ });
283
+
206
284
  it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => {
207
285
  const commitMutation = successfulCommitMutation();
208
286
  const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
@@ -15,7 +15,6 @@ import {
15
15
  } from "../utils/sdkCutover";
16
16
  import type { KeyframeCacheEntry } from "../player/store/playerStore";
17
17
  import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
18
- import { idFromSelector } from "./gsapShared";
19
18
  import {
20
19
  clearKeyframeCacheForElement,
21
20
  readKeyframeSnapshot,
@@ -336,11 +335,9 @@ export function useGsapKeyframeOps({
336
335
  const removeAllKeyframes = useCallback(
337
336
  async (selection: DomEditSelection, animationId: string) => {
338
337
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
339
- // remove-all-keyframes collapses the tween to a static hold and the commit
340
- // path doesn't return parsed animations, so the keyframe cache is never
341
- // refreshed clear it here so the timeline diamonds disappear immediately.
342
- const elementId = selection.id ?? idFromSelector(selection.selector);
343
- if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
338
+ // A class/descendant selector can resolve a live element whose selection
339
+ // deliberately has no id. The cache is still keyed by that DOM id.
340
+ const cacheElementId = selection.id || selection.element?.id;
344
341
  if (sdkSession && sdkDeps) {
345
342
  const handled = await sdkGsapRemoveAllKeyframesPersist(
346
343
  targetPath,
@@ -349,12 +346,26 @@ export function useGsapKeyframeOps({
349
346
  sdkDeps,
350
347
  { label: "Remove all keyframes" },
351
348
  );
352
- if (cutoverCommittedOrThrow(handled)) return;
349
+ if (cutoverCommittedOrThrow(handled)) {
350
+ if (cacheElementId) clearKeyframeCacheForElement(targetPath, cacheElementId);
351
+ return;
352
+ }
353
353
  }
354
- commitMutationSafely(
354
+ await commitMutationSafely(
355
355
  selection,
356
356
  { type: "remove-all-keyframes", animationId },
357
- { label: "Remove all keyframes", softReload: true },
357
+ {
358
+ label: "Remove all keyframes",
359
+ softReload: true,
360
+ // The committed result is the single success boundary: clearing
361
+ // before it makes failed saves lie, while waiting for the reload leaves
362
+ // stale diamonds visible during the source round-trip.
363
+ onResult: (result) => {
364
+ if (result.changed !== false && cacheElementId) {
365
+ clearKeyframeCacheForElement(targetPath, cacheElementId);
366
+ }
367
+ },
368
+ },
358
369
  );
359
370
  },
360
371
  [commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
3
+ import { drainStudioSaveQueues } from "./usePreviewPersistence";
4
+
5
+ describe("drainStudioSaveQueues", () => {
6
+ it("does not erase a pending-field conflict with a clean DOM queue", async () => {
7
+ const conflict = new StudioFileConflictError({
8
+ filePath: "index.html",
9
+ currentVersion: "v2",
10
+ currentContent: "external",
11
+ attemptedContent: "studio",
12
+ });
13
+ const waitForDomQueue = vi.fn(async () => ({ status: "clean" as const }));
14
+
15
+ await expect(
16
+ drainStudioSaveQueues(
17
+ async () => ({ status: "conflict" as const, error: conflict }),
18
+ waitForDomQueue,
19
+ ),
20
+ ).resolves.toEqual({ status: "conflict", error: conflict });
21
+ expect(waitForDomQueue).not.toHaveBeenCalled();
22
+ });
23
+ });
@@ -3,12 +3,14 @@ import { useMountEffect } from "./useMountEffect";
3
3
  import {
4
4
  installStudioManualEditSeekReapply,
5
5
  reapplyPositionEditsAfterSeek,
6
- readStudioFileChangePath,
7
6
  } from "../components/editor/manualEdits";
8
7
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
9
8
  import type { EditHistoryKind } from "../utils/editHistory";
10
- import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
11
- import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
9
+ import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
10
+ import {
11
+ flushStudioPendingEdits,
12
+ type StudioPendingEditsDrainResult,
13
+ } from "../utils/studioPendingEdits";
12
14
  import { trackStudioEvent } from "../utils/studioTelemetry";
13
15
  import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
14
16
  import { usePlayerStore } from "../player";
@@ -29,19 +31,12 @@ interface RecordEditInput {
29
31
  }
30
32
 
31
33
  interface UsePreviewPersistenceParams {
32
- projectId: string | null;
33
34
  showToast: (message: string, tone?: "error" | "info") => void;
34
35
  readOptionalProjectFile: (path: string) => Promise<string>;
35
36
  writeProjectFile: (path: string, content: string) => Promise<void>;
36
37
  recordEdit: (entry: RecordEditInput) => Promise<void>;
37
38
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
38
39
  activeCompPathRef: React.MutableRefObject<string | null>;
39
- /** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
40
- * Used to suppress file-change echoes so we don't reload after our own saves. */
41
- domEditSaveTimestampRef: React.MutableRefObject<number>;
42
- /** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change
43
- * events for these paths are always suppressed since the preview is already up-to-date. */
44
- pendingTimelineEditPathRef?: React.MutableRefObject<Set<string>>;
45
40
  /** Called to reload the preview after undo/redo or external file changes. */
46
41
  reloadPreview: () => void;
47
42
  }
@@ -71,19 +66,13 @@ function installManualEditReapply(iframe: HTMLIFrameElement): void {
71
66
  }
72
67
  }
73
68
 
74
- function shouldReloadForStudioFileChange(
75
- payload: unknown,
76
- pendingTimelineEditPathRef: React.MutableRefObject<Set<string>> | undefined,
77
- domEditSaveTimestampRef: React.MutableRefObject<number>,
78
- ): boolean {
79
- const changedPath = readStudioFileChangePath(payload);
80
- if (!changedPath) return false;
81
- const pendingTimelinePaths = pendingTimelineEditPathRef?.current;
82
- if (pendingTimelinePaths?.has(changedPath)) {
83
- pendingTimelinePaths.delete(changedPath);
84
- return false;
85
- }
86
- return Date.now() - domEditSaveTimestampRef.current >= 4000;
69
+ export async function drainStudioSaveQueues(
70
+ flushPendingFields: () => Promise<StudioPendingEditsDrainResult>,
71
+ waitForDomQueue: () => Promise<DomEditSaveDrainResult>,
72
+ ): Promise<StudioPendingEditsDrainResult | DomEditSaveDrainResult> {
73
+ const pending = await flushPendingFields();
74
+ if (pending.status !== "clean") return pending;
75
+ return waitForDomQueue();
87
76
  }
88
77
 
89
78
  // fallow-ignore-next-line complexity
@@ -107,16 +96,13 @@ async function clearLegacyStudioMotionFile(
107
96
  // ── Hook ──
108
97
 
109
98
  export function usePreviewPersistence({
110
- projectId,
111
99
  showToast,
112
100
  readOptionalProjectFile: _readOptionalProjectFile,
113
101
  writeProjectFile: _writeProjectFile,
114
102
  recordEdit: _recordEdit,
115
103
  previewIframeRef,
116
104
  activeCompPathRef,
117
- domEditSaveTimestampRef,
118
105
  reloadPreview,
119
- pendingTimelineEditPathRef,
120
106
  }: UsePreviewPersistenceParams) {
121
107
  void _recordEdit;
122
108
 
@@ -152,22 +138,23 @@ export function usePreviewPersistence({
152
138
  });
153
139
  }
154
140
 
155
- // Keep a ref to the latest projectId so async save callbacks always read the
156
- // current value, even when the callback was captured in a stale closure.
157
- const projectIdRef = useRef(projectId);
158
- projectIdRef.current = projectId;
159
-
160
141
  // ── Queue / drain helpers ──
161
142
 
162
143
  const queueDomEditSave = useCallback(<T>(save: () => Promise<T>): Promise<T> => {
163
144
  return domEditSaveQueueRef.current?.enqueue(save) ?? save();
164
145
  }, []);
165
146
 
166
- const waitForPendingDomEditSaves = useCallback(async () => {
167
- await flushStudioPendingEdits();
168
- await domEditSaveQueueRef.current?.waitForIdle();
147
+ const drainPendingDomEditSaves = useCallback(async () => {
148
+ return drainStudioSaveQueues(flushStudioPendingEdits, async () => {
149
+ return (await domEditSaveQueueRef.current?.waitForIdle()) ?? { status: "clean" as const };
150
+ });
169
151
  }, []);
170
152
 
153
+ const waitForPendingDomEditSaves = useCallback(async (): Promise<void> => {
154
+ const result = await drainPendingDomEditSaves();
155
+ if (result.status !== "clean") throw result.error;
156
+ }, [drainPendingDomEditSaves]);
157
+
171
158
  const resetDomEditSaveQueueBreaker = useCallback(() => {
172
159
  domEditSaveQueueRef.current?.reset();
173
160
  setDomEditSaveQueuePaused(null);
@@ -235,35 +222,12 @@ export function usePreviewPersistence({
235
222
  void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
236
223
  });
237
224
 
238
- // ── Listen for external file changes (HMR / SSE) ──
239
- useMountEffect(() => {
240
- const handler = (payload?: unknown) => {
241
- if (
242
- shouldReloadForStudioFileChange(
243
- payload,
244
- pendingTimelineEditPathRef,
245
- domEditSaveTimestampRef,
246
- )
247
- ) {
248
- // fallow-ignore-next-line code-duplication
249
- reloadPreview();
250
- }
251
- };
252
- if (import.meta.hot) {
253
- import.meta.hot.on("hf:file-change", handler);
254
- return () => import.meta.hot?.off?.("hf:file-change", handler);
255
- }
256
- // SSE fallback for embedded studio server
257
- const es = new EventSource("/api/events");
258
- es.addEventListener("file-change", handler);
259
- return () => es.close();
260
- });
261
-
262
225
  return {
263
226
  domTextCommitVersionRef,
264
227
  domEditSaveQueueRef,
265
228
  applyStudioManualEditsToPreviewRef,
266
229
  queueDomEditSave,
230
+ drainPendingDomEditSaves,
267
231
  waitForPendingDomEditSaves,
268
232
  domEditSaveQueuePaused,
269
233
  resetDomEditSaveQueueBreaker,
@@ -1,11 +1,11 @@
1
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
- import type { MutableRefObject } from "react";
3
2
  import { openComposition } from "@hyperframes/sdk";
4
3
  import type { Composition } from "@hyperframes/sdk";
5
4
  import { readStudioFileChangePath } from "../components/editor/manualEdits";
6
5
  import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
7
6
  import { trackStudioEvent } from "../utils/studioTelemetry";
8
7
  import type { PublishSdkSession } from "../utils/sdkCutover";
8
+ import { addExternalFileReloadListener } from "./externalFileReloadBus";
9
9
 
10
10
  /**
11
11
  * Read a project file's content, or undefined on a non-2xx (optional read).
@@ -48,22 +48,6 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
48
48
  * stale. The session has NO persist queue — Studio is the sole file writer; see
49
49
  * the open effect below.
50
50
  */
51
- // Reload-suppression baseline: a file-change within this window of our own SDK
52
- // cutover write is a CANDIDATE echo, but the decision is content-identity based
53
- // (isSelfWriteEcho) not time-only — so an undo write that lands inside the window
54
- // still reloads (its reverted bytes were never registered as a self-write). The
55
- // window only bounds how long a registered self-write stays suppressible.
56
- const SELF_WRITE_SUPPRESS_MS = 2000;
57
-
58
- /** Best-effort read of the changed file's content from a file-change payload. */
59
- function readFileChangeContent(payload: unknown): string | null {
60
- if (!payload || typeof payload !== "object") return null;
61
- const record = payload as Record<string, unknown>;
62
- if (typeof record.content === "string") return record.content;
63
- if ("data" in record) return readFileChangeContent(record.data);
64
- return null;
65
- }
66
-
67
51
  /**
68
52
  * Decide whether a file-change for the active composition should reload the SDK
69
53
  * session. `content` is the new on-disk bytes (from the payload or a re-read);
@@ -157,7 +141,6 @@ function disposeSdkSession(session: Composition): void {
157
141
  export function useSdkSession(
158
142
  projectId: string | null,
159
143
  activeCompPath: string | null,
160
- domEditSaveTimestampRef?: MutableRefObject<number>,
161
144
  ): SdkSessionHandle {
162
145
  const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
163
146
  const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
@@ -171,40 +154,13 @@ export function useSdkSession(
171
154
  const reloadTokenRef = useRef(reloadToken);
172
155
  reloadTokenRef.current = reloadToken;
173
156
 
174
- // ── Re-open on external change to the active composition ──
175
- useEffect(() => {
176
- if (!activeCompPath) return;
177
- const compPath = activeCompPath;
178
- const readProjectId = projectId ?? null;
179
- const handler = (payload?: unknown) => {
180
- if (!shouldReloadSdkSession(payload, compPath)) return;
181
- const withinWindow =
182
- !!domEditSaveTimestampRef &&
183
- Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS;
184
- const decide = (content: string | null) => {
185
- if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1);
186
- };
187
- const payloadContent = readFileChangeContent(payload);
188
- // Prefer payload content; otherwise re-read so the decision is by IDENTITY
189
- // (an undo's reverted bytes won't match a registered self-write → reload).
190
- if (payloadContent != null || readProjectId == null) {
191
- decide(payloadContent);
192
- return;
193
- }
194
- readProjectFileOptional(readProjectId, compPath)
195
- .then((c) => decide(c ?? null))
196
- .catch(() => decide(null));
197
- };
198
- if (import.meta.hot) {
199
- import.meta.hot.on("hf:file-change", handler);
200
- return () => import.meta.hot?.off?.("hf:file-change", handler);
201
- }
202
- // SSE fallback for the embedded studio server.
203
- const es = new EventSource("/api/events");
204
- es.addEventListener("file-change", handler);
205
- return () => es.close();
206
- // eslint-disable-next-line react-hooks/exhaustive-deps
207
- }, [activeCompPath, projectId]);
157
+ useEffect(
158
+ () =>
159
+ addExternalFileReloadListener((changedPath) => {
160
+ if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1);
161
+ }),
162
+ [],
163
+ );
208
164
 
209
165
  // ── Open / re-open the session ──
210
166
  useEffect(() => {
@@ -0,0 +1,81 @@
1
+ import { useCallback, type MutableRefObject } from "react";
2
+ import {
3
+ deleteExternalConflictSnapshot,
4
+ loadExternalConflictSnapshot,
5
+ persistExternalConflictSnapshot,
6
+ persistExternalFailureSnapshot,
7
+ } from "../utils/externalConflictStorage";
8
+ import { notifyExternalFileReload } from "./externalFileReloadBus";
9
+ import { useExternalFileChangeCoordinator } from "./useExternalFileChangeCoordinator";
10
+ import type { useFileManager } from "./useFileManager";
11
+ import type { usePreviewPersistence } from "./usePreviewPersistence";
12
+
13
+ type FileManager = Pick<
14
+ ReturnType<typeof useFileManager>,
15
+ | "editingFile"
16
+ | "flushPendingSourceSave"
17
+ | "discardPendingSourceSave"
18
+ | "getPendingSourceCandidate"
19
+ | "overwriteExternalConflict"
20
+ | "readProjectFile"
21
+ | "updateEditingFileContent"
22
+ >;
23
+
24
+ type PreviewPersistence = Pick<
25
+ ReturnType<typeof usePreviewPersistence>,
26
+ "drainPendingDomEditSaves" | "resetDomEditSaveQueueBreaker"
27
+ >;
28
+
29
+ interface UseStudioExternalFileChangesOptions {
30
+ projectId: string | null;
31
+ activeCompPath: string | null;
32
+ masterCompPath: string | null;
33
+ fileManager: FileManager;
34
+ previewPersistence: PreviewPersistence;
35
+ pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
36
+ reloadPreview: () => void;
37
+ }
38
+
39
+ /** Connects the app's save queues, recovery storage, and reload surfaces to one owner. */
40
+ export function useStudioExternalFileChanges({
41
+ projectId,
42
+ activeCompPath,
43
+ masterCompPath,
44
+ fileManager,
45
+ previewPersistence,
46
+ pendingTimelineEditPathRef,
47
+ reloadPreview,
48
+ }: UseStudioExternalFileChangesOptions) {
49
+ const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
50
+ const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
51
+ const drainPendingChanges = useCallback(async () => {
52
+ const source = await flushPendingSourceSave();
53
+ if (source.status !== "clean") return source;
54
+ return drainPendingDomEditSaves();
55
+ }, [drainPendingDomEditSaves, flushPendingSourceSave]);
56
+
57
+ const discardPendingChanges = useCallback(() => {
58
+ discardPendingSourceSave();
59
+ resetDomEditSaveQueueBreaker();
60
+ }, [discardPendingSourceSave, resetDomEditSaveQueueBreaker]);
61
+
62
+ return useExternalFileChangeCoordinator({
63
+ projectId,
64
+ activeCompPath,
65
+ recoveryFilePath: fileManager.editingFile?.path ?? activeCompPath ?? masterCompPath,
66
+ pendingTimelineEditPathRef,
67
+ drainPendingChanges,
68
+ getPendingCandidate: fileManager.getPendingSourceCandidate,
69
+ discardPendingChanges,
70
+ reloadPreview,
71
+ reloadSdkSession: notifyExternalFileReload,
72
+ persistConflictSnapshot: persistExternalConflictSnapshot,
73
+ persistFailureSnapshot: persistExternalFailureSnapshot,
74
+ loadConflictSnapshot: loadExternalConflictSnapshot,
75
+ deleteConflictSnapshot: deleteExternalConflictSnapshot,
76
+ overwriteConflict: fileManager.overwriteExternalConflict,
77
+ readProjectFile: fileManager.readProjectFile,
78
+ onUseExternalFile: fileManager.updateEditingFileContent,
79
+ resetSaveQueues: resetDomEditSaveQueueBreaker,
80
+ });
81
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, type MutableRefObject } from "react";
1
+ import { useEffect } from "react";
2
2
  import { useSdkSession } from "./useSdkSession";
3
3
  import { usePreviewVariablesStore } from "./previewVariablesStore";
4
4
 
@@ -17,18 +17,13 @@ import { usePreviewVariablesStore } from "./previewVariablesStore";
17
17
  export function useStudioSdkSessions(
18
18
  projectId: string | null,
19
19
  activeCompPath: string | null,
20
- domEditSaveTimestampRef: MutableRefObject<number>,
21
20
  masterCompPath: string | null,
22
21
  ) {
23
22
  // On the master view (no explicit comp) the schema panels target the project's
24
23
  // resolved main composition — the first `.html` in the tree, not a hardcoded
25
24
  // "index.html" that may not exist. `null` when the project has no composition
26
25
  // yet, which correctly leaves the session (and the panels) empty.
27
- const sdkHandle = useSdkSession(
28
- projectId,
29
- activeCompPath ?? masterCompPath,
30
- domEditSaveTimestampRef,
31
- );
26
+ const sdkHandle = useSdkSession(projectId, activeCompPath ?? masterCompPath);
32
27
  const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
33
28
  useEffect(() => {
34
29
  usePreviewVariablesStore.getState().setValues(null);
@@ -0,0 +1,15 @@
1
+ import { useEffect } from "react";
2
+ import { hasFiredSessionStart, markSessionStartFired } from "../telemetry/config";
3
+ import { trackStudioSessionStart } from "../telemetry/events";
4
+
5
+ export function useStudioSessionStart(
6
+ projectId: string | null,
7
+ resolving: boolean,
8
+ waitingForServer: boolean,
9
+ ): void {
10
+ useEffect(() => {
11
+ if (resolving || waitingForServer || hasFiredSessionStart()) return;
12
+ markSessionStartFired();
13
+ trackStudioSessionStart({ has_project: projectId != null });
14
+ }, [projectId, resolving, waitingForServer]);
15
+ }
@@ -59,11 +59,15 @@ describe("timeline performance fixture", () => {
59
59
  expect(Math.max(...perTrack.values())).toBeLessThanOrEqual(128);
60
60
  });
61
61
 
62
- it.each(PROFILES)("generates an identical 50k %s fixture", (profile) => {
63
- const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
64
- const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
65
- expect(second).toEqual(first);
66
- });
62
+ it.each(PROFILES)(
63
+ "generates an identical 50k %s fixture",
64
+ (profile) => {
65
+ const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
66
+ const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
67
+ expect(second).toEqual(first);
68
+ },
69
+ 30_000,
70
+ );
67
71
 
68
72
  it.each(PROFILES)("builds the %s 1k scale profile", (profile) => {
69
73
  const fixture = createTimelinePerformanceFixture({ elementCount: 1_000, profile });
@@ -90,7 +94,7 @@ describe("timeline performance fixture", () => {
90
94
  usePlayerStore.setState({
91
95
  isPlaying: true,
92
96
  requestedSeekTime: 42,
93
- clipRevealRequest: { elementId: "stale", nonce: 7 },
97
+ timelineFocus: { id: "stale", projectId: null, sessionEpoch: 0, nonce: 7 },
94
98
  clipManifest: [],
95
99
  lintFindingsByElement: new Map([["stale", { count: 1, messages: ["stale"] }]]),
96
100
  });
@@ -108,7 +112,7 @@ describe("timeline performance fixture", () => {
108
112
  expect(usePlayerStore.getState()).toMatchObject({
109
113
  isPlaying: false,
110
114
  requestedSeekTime: null,
111
- clipRevealRequest: null,
115
+ timelineFocus: null,
112
116
  clipManifest: null,
113
117
  duration: 600,
114
118
  timelineReady: true,