@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
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
2
  import type { CanvasResolution } from "@hyperframes/parsers";
3
3
  import { trackStudioRenderStart } from "../../telemetry/events";
4
4
  import { getAnonymousId } from "../../telemetry/config";
5
+ import { browserTelemetryAllowed } from "../../telemetry/policy";
5
6
  import { generateId } from "../../utils/generateId";
6
7
 
7
8
  export interface RenderJob {
@@ -157,16 +158,28 @@ export function useRenderQueue(projectId: string | null) {
157
158
  resolution?: string;
158
159
  composition?: string;
159
160
  variables?: Record<string, unknown>;
160
- telemetryDistinctId: string;
161
+ telemetryDistinctId?: string;
162
+ telemetryOptOut?: boolean;
161
163
  } = {
162
164
  fps,
163
165
  quality,
164
166
  format,
167
+ };
168
+ // The id is MINTED by getAnonymousId(), so calling it unconditionally
169
+ // created a telemetry identity for a profile that had opted out — and
170
+ // then shipped it to the server. The server's own policy cannot see this
171
+ // browser's localStorage or DoNotTrack, so it has to be told: an
172
+ // explicit `telemetryOptOut` suppresses the render outcome, which
173
+ // omitting the id alone does NOT (an old client omits it too, and that
174
+ // falls back to the install id).
175
+ if (browserTelemetryAllowed()) {
165
176
  // So the server-emitted render_complete/render_error is attributed to
166
177
  // this browser user (same id studio_* events use), making the render
167
178
  // funnel joinable. Matches studio_render_start fired just above.
168
- telemetryDistinctId: getAnonymousId(),
169
- };
179
+ body.telemetryDistinctId = getAnonymousId();
180
+ } else {
181
+ body.telemetryOptOut = true;
182
+ }
170
183
  if (resolution && resolution !== "auto") body.resolution = resolution;
171
184
  if (composition) body.composition = composition;
172
185
  if (opts.variables && Object.keys(opts.variables).length > 0) {
@@ -0,0 +1,104 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // The render request body is the only place the browser can tell the CLI that
4
+ // this profile opted out. The CLI's own policy cannot see localStorage or
5
+ // DoNotTrack in someone else's browser, so if the body says nothing, the
6
+ // server emits render_complete / render_error anyway — attributed to the
7
+ // install id rather than the user's, which is worse than attributing it
8
+ // correctly.
9
+
10
+ import { act } from "react";
11
+ import { createRoot, type Root } from "react-dom/client";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ const policyState = { allowed: true };
15
+ const mintCalls = vi.fn(() => "browser-user-123");
16
+
17
+ vi.mock("../../telemetry/policy", () => ({
18
+ browserTelemetryAllowed: () => policyState.allowed,
19
+ }));
20
+ vi.mock("../../telemetry/config", () => ({
21
+ getAnonymousId: () => mintCalls(),
22
+ }));
23
+ vi.mock("../../telemetry/events", () => ({
24
+ trackStudioRenderStart: vi.fn(),
25
+ }));
26
+
27
+ const { useRenderQueue } = await import("./useRenderQueue");
28
+
29
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
30
+
31
+ let root: Root | null = null;
32
+
33
+ /** Body of the POST the hook makes when a render is started. */
34
+ async function startRenderBody(): Promise<Record<string, unknown>> {
35
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
36
+ Promise.resolve(
37
+ new Response(JSON.stringify({ jobId: "j1", status: "rendering" }), {
38
+ status: 200,
39
+ headers: { "content-type": "application/json" },
40
+ }),
41
+ ),
42
+ );
43
+ vi.stubGlobal("fetch", fetchMock);
44
+ vi.stubGlobal(
45
+ "EventSource",
46
+ class {
47
+ close(): void {}
48
+ addEventListener(): void {}
49
+ },
50
+ );
51
+
52
+ let api: ReturnType<typeof useRenderQueue> | null = null;
53
+ function Harness(): null {
54
+ api = useRenderQueue("demo");
55
+ return null;
56
+ }
57
+ const host = document.createElement("div");
58
+ document.body.append(host);
59
+ root = createRoot(host);
60
+ act(() => {
61
+ root?.render(<Harness />);
62
+ });
63
+ await act(async () => {
64
+ await api?.startRender({ fps: 30, quality: "standard", format: "mp4" });
65
+ });
66
+
67
+ const post = fetchMock.mock.calls.find(([, init]) => init?.method === "POST");
68
+ const body = post?.[1]?.body;
69
+ if (body === undefined || body === null) throw new Error("hook made no POST with a body");
70
+ return JSON.parse(String(body)) as Record<string, unknown>;
71
+ }
72
+
73
+ beforeEach(() => {
74
+ policyState.allowed = true;
75
+ mintCalls.mockClear();
76
+ });
77
+
78
+ afterEach(() => {
79
+ if (root) act(() => root?.unmount());
80
+ root = null;
81
+ document.body.innerHTML = "";
82
+ vi.unstubAllGlobals();
83
+ });
84
+
85
+ describe("render request telemetry fields", () => {
86
+ it("sends the browser id when this profile allows telemetry", async () => {
87
+ const body = await startRenderBody();
88
+ expect(body["telemetryDistinctId"]).toBe("browser-user-123");
89
+ expect(body["telemetryOptOut"]).toBeUndefined();
90
+ });
91
+
92
+ it("mints no id and says so explicitly when the profile opted out", async () => {
93
+ policyState.allowed = false;
94
+ const body = await startRenderBody();
95
+ // Minting is itself the leak: getAnonymousId() CREATES and persists an
96
+ // identity, so calling it for an opted-out profile is wrong even if the
97
+ // value were never sent.
98
+ expect(mintCalls).not.toHaveBeenCalled();
99
+ expect(body["telemetryDistinctId"]).toBeUndefined();
100
+ // Omission alone would be read as an old client and fall back to the
101
+ // install id — the flag is what actually suppresses the server event.
102
+ expect(body["telemetryOptOut"]).toBe(true);
103
+ });
104
+ });
@@ -8,6 +8,7 @@ import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
8
8
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
9
9
  import { ContextMenu } from "./AssetContextMenu";
10
10
  import { usePlayerStore } from "../../player/store/playerStore";
11
+ import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
11
12
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
12
13
  import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
13
14
  import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
@@ -133,7 +134,7 @@ export function AssetCard({
133
134
  const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
134
135
 
135
136
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
136
- const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
137
+ const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
137
138
  const elements = usePlayerStore((s) => s.elements);
138
139
  const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
139
140
  const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
@@ -158,7 +159,7 @@ export function AssetCard({
158
159
  const clipKey = clip.key ?? clip.id;
159
160
  setSelectedElementId(clipKey);
160
161
  // Scroll the timeline so the selected clip is actually visible.
161
- requestClipReveal(clipKey);
162
+ requestTimelineFocus(timelineClipFocusId(clipKey));
162
163
  return;
163
164
  }
164
165
  }
@@ -171,7 +172,7 @@ export function AssetCard({
171
172
  asset,
172
173
  projectId,
173
174
  setSelectedElementId,
174
- requestClipReveal,
175
+ requestTimelineFocus,
175
176
  setPreviewAsset,
176
177
  clearPreviewAsset,
177
178
  ],
@@ -6,6 +6,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
6
6
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
7
7
  import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
8
8
  import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
9
+ import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
9
10
 
10
11
  export function AudioRow({
11
12
  projectId,
@@ -43,7 +44,7 @@ export function AudioRow({
43
44
  // CapCut-style click behavior: drag-threshold gate.
44
45
  const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
45
46
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
46
- const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
47
+ const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
47
48
  const elements = usePlayerStore((s) => s.elements);
48
49
  const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
49
50
  const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
@@ -67,7 +68,7 @@ export function AudioRow({
67
68
  const clipKey = clip.key ?? clip.id;
68
69
  setSelectedElementId(clipKey);
69
70
  // Scroll the timeline so the selected clip is actually visible.
70
- requestClipReveal(clipKey);
71
+ requestTimelineFocus(timelineClipFocusId(clipKey));
71
72
  return;
72
73
  }
73
74
  }
@@ -80,7 +81,7 @@ export function AudioRow({
80
81
  asset,
81
82
  projectId,
82
83
  setSelectedElementId,
83
- requestClipReveal,
84
+ requestTimelineFocus,
84
85
  setPreviewAsset,
85
86
  clearPreviewAsset,
86
87
  ],
@@ -200,8 +200,8 @@ export function DomEditProvider({
200
200
  commitMutation,
201
201
  applyMarqueeSelection,
202
202
  handleUpdateKeyframeEase,
203
- handleSetAllKeyframeEases,
204
203
  handleUpdateSegmentEase,
204
+ handleSetAllKeyframeEases,
205
205
  },
206
206
  children,
207
207
  }: {
@@ -27,9 +27,13 @@ export function FileManagerProvider({
27
27
  editingPathRef,
28
28
  projectIdRef,
29
29
  saveRafRef,
30
+ flushPendingSourceSave,
31
+ discardPendingSourceSave,
32
+ getPendingSourceCandidate,
30
33
  importedFontAssetsRef,
31
34
  readProjectFile,
32
35
  writeProjectFile,
36
+ overwriteExternalConflict,
33
37
  readOptionalProjectFile,
34
38
  observeProjectFileVersion,
35
39
  updateEditingFileContent,
@@ -67,9 +71,13 @@ export function FileManagerProvider({
67
71
  editingPathRef,
68
72
  projectIdRef,
69
73
  saveRafRef,
74
+ flushPendingSourceSave,
75
+ discardPendingSourceSave,
76
+ getPendingSourceCandidate,
70
77
  importedFontAssetsRef,
71
78
  readProjectFile,
72
79
  writeProjectFile,
80
+ overwriteExternalConflict,
73
81
  readOptionalProjectFile,
74
82
  observeProjectFileVersion,
75
83
  updateEditingFileContent,
@@ -101,9 +109,13 @@ export function FileManagerProvider({
101
109
  editingPathRef,
102
110
  projectIdRef,
103
111
  saveRafRef,
112
+ flushPendingSourceSave,
113
+ discardPendingSourceSave,
114
+ getPendingSourceCandidate,
104
115
  importedFontAssetsRef,
105
116
  readProjectFile,
106
117
  writeProjectFile,
118
+ overwriteExternalConflict,
107
119
  readOptionalProjectFile,
108
120
  observeProjectFileVersion,
109
121
  updateEditingFileContent,
@@ -0,0 +1,17 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ describe("external file-change subscription ownership", () => {
5
+ it("has one subscriber instead of independent Preview and SDK listeners", () => {
6
+ const preview = readFileSync(new URL("./usePreviewPersistence.ts", import.meta.url), "utf8");
7
+ const sdk = readFileSync(new URL("./useSdkSession.ts", import.meta.url), "utf8");
8
+ const coordinator = readFileSync(
9
+ new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
10
+ "utf8",
11
+ );
12
+
13
+ expect(preview).not.toContain('hot.on("hf:file-change"');
14
+ expect(sdk).not.toContain('hot.on("hf:file-change"');
15
+ expect(coordinator.match(/hot\.on\("hf:file-change"/g)).toHaveLength(1);
16
+ });
17
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { addExternalFileReloadListener, notifyExternalFileReload } from "./externalFileReloadBus";
3
+
4
+ describe("external file reload bus", () => {
5
+ it("lets the sole watcher reload every SDK owner of the changed path", () => {
6
+ const first = vi.fn();
7
+ const second = vi.fn();
8
+ const removeFirst = addExternalFileReloadListener(first);
9
+ const removeSecond = addExternalFileReloadListener(second);
10
+
11
+ notifyExternalFileReload("scenes/card.html");
12
+ expect(first).toHaveBeenCalledWith("scenes/card.html");
13
+ expect(second).toHaveBeenCalledWith("scenes/card.html");
14
+
15
+ removeFirst();
16
+ removeSecond();
17
+ });
18
+
19
+ it("isolates a broken listener so later SDK owners still reload", () => {
20
+ const broken = vi.fn(() => {
21
+ throw new Error("stale owner");
22
+ });
23
+ const healthy = vi.fn();
24
+ const removeBroken = addExternalFileReloadListener(broken);
25
+ const removeHealthy = addExternalFileReloadListener(healthy);
26
+
27
+ expect(() => notifyExternalFileReload("scenes/card.html")).not.toThrow();
28
+ expect(broken).toHaveBeenCalledWith("scenes/card.html");
29
+ expect(healthy).toHaveBeenCalledWith("scenes/card.html");
30
+
31
+ removeBroken();
32
+ removeHealthy();
33
+ });
34
+ });
@@ -0,0 +1,18 @@
1
+ type ExternalFileReloadListener = (path: string) => void;
2
+
3
+ const listeners = new Set<ExternalFileReloadListener>();
4
+
5
+ export function addExternalFileReloadListener(listener: ExternalFileReloadListener): () => void {
6
+ listeners.add(listener);
7
+ return () => listeners.delete(listener);
8
+ }
9
+
10
+ export function notifyExternalFileReload(path: string): void {
11
+ for (const listener of listeners) {
12
+ try {
13
+ listener(path);
14
+ } catch {
15
+ // A stale SDK owner must not prevent sibling owners from reloading.
16
+ }
17
+ }
18
+ }
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { describe, expect, it, beforeEach } from "vitest";
3
+ import { describe, expect, it, beforeEach, vi } from "vitest";
4
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
5
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
6
  import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
@@ -9,6 +9,7 @@ import {
9
9
  commitStaticGsapRotation,
10
10
  commitStaticGsapSize,
11
11
  findExistingPositionWrite,
12
+ materializeIfDynamic,
12
13
  parkPlayheadOnKeyframe,
13
14
  type GsapDragCommitCallbacks,
14
15
  } from "./gsapDragCommit";
@@ -27,6 +28,43 @@ const selection = (): DomEditSelection =>
27
28
  },
28
29
  }) as unknown as DomEditSelection;
29
30
 
31
+ function selectorlessSelection(): DomEditSelection {
32
+ return {
33
+ selector: ".shared",
34
+ element: document.createElement("div"),
35
+ } as unknown as DomEditSelection;
36
+ }
37
+
38
+ describe("lower GSAP commit helpers fail closed", () => {
39
+ it("rejects instead of silently dropping a static position write without a stable selector", async () => {
40
+ const commitMutation = vi.fn();
41
+ await expect(
42
+ commitStaticGsapPosition(
43
+ selectorlessSelection(),
44
+ { x: 10, y: 20 },
45
+ { x: 0, y: 0 },
46
+ ".shared",
47
+ null,
48
+ { commitMutation },
49
+ ),
50
+ ).rejects.toMatchObject({ name: "GsapEditBlockedError", reason: "no-selector" });
51
+ expect(commitMutation).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it("rejects runtime-dynamic materialization instead of rewriting source during a gesture", async () => {
55
+ const commitMutation = vi.fn();
56
+ await expect(
57
+ materializeIfDynamic(
58
+ { ...flatTween(), hasUnresolvedKeyframes: true },
59
+ null,
60
+ commitMutation,
61
+ selection(),
62
+ ),
63
+ ).rejects.toMatchObject({ name: "GsapEditBlockedError", reason: "source-uneditable" });
64
+ expect(commitMutation).not.toHaveBeenCalled();
65
+ });
66
+ });
67
+
30
68
  const flatTween = (): GsapAnimation =>
31
69
  ({
32
70
  id: "#puck-a-to",
@@ -9,14 +9,14 @@ import {
9
9
  STUDIO_ORIGINAL_HEIGHT_ATTR,
10
10
  } from "../components/editor/manualEditsTypes";
11
11
  import { usePlayerStore } from "../player/store/playerStore";
12
- import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
13
12
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
14
13
  import { roundTo3 } from "../utils/rounding";
15
- import { computeElementPercentage, idSelector, writeTargetSelector } from "./gsapShared";
14
+ import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
16
15
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
17
16
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
18
17
  import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
19
18
  import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
19
+ import { GsapEditBlockedError } from "./gsapEditOutcome";
20
20
  export {
21
21
  findExistingPositionWrite,
22
22
  findRotationSetAnimation,
@@ -87,7 +87,7 @@ async function replaceKeyframedPositionHold(
87
87
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
88
88
  ): Promise<void> {
89
89
  const target = newTweenTarget(selection);
90
- if (!target) return;
90
+ if (!target) throw new GsapEditBlockedError("no-selector");
91
91
  const persist = async (commit: GsapDragCommitCallbacks["commitMutation"]) => {
92
92
  await commit(
93
93
  selection,
@@ -130,40 +130,12 @@ export async function materializeIfDynamic(
130
130
  selection: DomEditSelection,
131
131
  ): Promise<string | void> {
132
132
  if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
133
-
134
- if (anim.hasUnresolvedSelector) {
135
- const allScanned = scanAllRuntimeKeyframes(iframe);
136
- if (allScanned.size === 0) return;
137
- const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
138
- selector: idSelector(id),
139
- keyframes: data.keyframes,
140
- easeEach: data.easeEach,
141
- }));
142
- await commitMutation(
143
- selection,
144
- {
145
- type: "materialize-keyframes",
146
- animationId: anim.id,
147
- keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
148
- allElements,
149
- },
150
- { label: "Unroll dynamic animations", skipReload: true },
151
- );
152
- return `${anim.targetSelector}-to-0`;
153
- }
154
-
155
- const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
156
- if (!runtime || runtime.keyframes.length === 0) return;
157
- await commitMutation(
158
- selection,
159
- {
160
- type: "materialize-keyframes",
161
- animationId: anim.id,
162
- keyframes: runtime.keyframes,
163
- easeEach: runtime.easeEach,
164
- },
165
- { label: "Materialize dynamic keyframes", skipReload: true },
166
- );
133
+ // Geometry commits must never rewrite runtime/computed source implicitly.
134
+ // The explicit Unroll action owns that source-destructive transition.
135
+ void iframe;
136
+ void commitMutation;
137
+ void selection;
138
+ throw new GsapEditBlockedError("source-uneditable");
167
139
  }
168
140
 
169
141
  // ── Drag → GSAP position math ──────────────────────────────────────────────
@@ -219,7 +191,7 @@ export async function commitStaticGsapPosition(
219
191
  // The patch reuses the WRITTEN target so the runtime moves exactly the element
220
192
  // the source write names.
221
193
  const target = newTweenTarget(selection);
222
- if (!target) return;
194
+ if (!target) throw new GsapEditBlockedError("no-selector");
223
195
  await callbacks.commitMutation(
224
196
  selection,
225
197
  {
@@ -277,7 +249,7 @@ export async function commitStaticGsapRotation(
277
249
  }
278
250
  // New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
279
251
  const target = newTweenTarget(selection);
280
- if (!target) return;
252
+ if (!target) throw new GsapEditBlockedError("no-selector");
281
253
  await callbacks.commitMutation(
282
254
  selection,
283
255
  {
@@ -331,7 +303,7 @@ export async function commitStaticGsapSize(
331
303
  return;
332
304
  }
333
305
  const target = newTweenTarget(selection);
334
- if (!target) return;
306
+ if (!target) throw new GsapEditBlockedError("no-selector");
335
307
  await callbacks.commitMutation(
336
308
  selection,
337
309
  {
@@ -0,0 +1,72 @@
1
+ import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+
3
+ export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
4
+
5
+ export type GsapEditOutcome =
6
+ | { status: "persisted" }
7
+ | { status: "blocked"; reason: GsapEditBlockReason };
8
+
9
+ const COPY: Record<GsapEditBlockReason, string> = {
10
+ "no-selector": "This layer needs a stable selector before Studio can save the edit.",
11
+ "unroll-required":
12
+ "This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
13
+ "source-uneditable": "This animation is computed at runtime. Edit the animation in the Code tab.",
14
+ };
15
+
16
+ export class GsapEditBlockedError extends Error {
17
+ constructor(readonly reason: GsapEditBlockReason) {
18
+ super(COPY[reason]);
19
+ this.name = "GsapEditBlockedError";
20
+ }
21
+ }
22
+
23
+ export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
24
+ if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason);
25
+ }
26
+
27
+ function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
28
+ const editability = editabilityForProvenance(animation.provenance);
29
+ if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
30
+ if (
31
+ editability === "source" ||
32
+ animation.hasUnresolvedKeyframes ||
33
+ animation.hasUnresolvedSelector
34
+ ) {
35
+ throw new GsapEditBlockedError("source-uneditable");
36
+ }
37
+ }
38
+
39
+ export function isGsapEditBlockedError(error: unknown): error is GsapEditBlockedError {
40
+ return error instanceof GsapEditBlockedError;
41
+ }
42
+
43
+ export function animationWritesAnyProperty(
44
+ animation: GsapAnimation,
45
+ properties: ReadonlySet<string>,
46
+ ): boolean {
47
+ return (
48
+ Object.keys(animation.properties ?? {}).some((property) => properties.has(property)) ||
49
+ Object.keys(animation.fromProperties ?? {}).some((property) => properties.has(property)) ||
50
+ !!animation.keyframes?.keyframes.some((keyframe) =>
51
+ Object.keys(keyframe.properties).some((property) => properties.has(property)),
52
+ )
53
+ );
54
+ }
55
+
56
+ /** Fail-closed ownership check shared by drag, resize, rotate, and inspector edits. */
57
+ export function directEditOutcomeForProperties(
58
+ animations: GsapAnimation[],
59
+ properties: ReadonlySet<string>,
60
+ ): GsapEditOutcome {
61
+ try {
62
+ for (const animation of animations) {
63
+ if (animationWritesAnyProperty(animation, properties)) {
64
+ assertGsapAnimationDirectlyEditable(animation);
65
+ }
66
+ }
67
+ return { status: "persisted" };
68
+ } catch (error) {
69
+ if (isGsapEditBlockedError(error)) return { status: "blocked", reason: error.reason };
70
+ throw error;
71
+ }
72
+ }