@hyperframes/studio 0.8.115 → 0.8.117

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 (163) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
  2. package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
  3. package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
  4. package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.js} +1 -1
  5. package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
  6. package/dist/chunk-3F47EFCT.js.map +1 -0
  7. package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
  8. package/dist/index.d.ts +132 -41
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1184 -1084
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/captions/hooks/useCaptionSync.ts +10 -6
  14. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  15. package/src/components/editor/DomEditOverlay.tsx +1 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  17. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  18. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  19. package/src/components/editor/domGroupMoveBy.ts +95 -0
  20. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  21. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  22. package/src/components/editor/manualEditingAvailability.ts +8 -0
  23. package/src/components/editor/manualOffsetDrag.ts +8 -9
  24. package/src/components/editor/motionPathSelection.test.ts +6 -31
  25. package/src/components/editor/plainTranslate.ts +5 -3
  26. package/src/components/editor/probeSourceElement.ts +2 -1
  27. package/src/components/editor/propertyPanelFont.tsx +3 -2
  28. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  29. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  30. package/src/components/editor/rotationDraft.ts +2 -1
  31. package/src/components/editor/useColorGradingController.ts +2 -1
  32. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  33. package/src/components/editor/useFxCarveNodes.ts +2 -1
  34. package/src/components/editor/useFxLevelling.ts +2 -1
  35. package/src/components/feedback/projectProvenance.ts +2 -1
  36. package/src/components/nle/NLEContext.test.ts +1 -0
  37. package/src/components/nle/NLEContext.tsx +2 -1
  38. package/src/components/nle/NLEPreview.tsx +12 -10
  39. package/src/components/nle/PreviewPoster.tsx +2 -1
  40. package/src/components/renders/useFfmpegStatus.ts +2 -1
  41. package/src/components/renders/useRenderQueue.ts +5 -4
  42. package/src/components/sidebar/AssetsTab.tsx +2 -1
  43. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  44. package/src/contexts/DomEditContext.tsx +4 -0
  45. package/src/hooks/domSelectionTestHarness.ts +23 -0
  46. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  47. package/src/hooks/domStyleCommit.ts +8 -1
  48. package/src/hooks/draggedGsapPosition.ts +10 -0
  49. package/src/hooks/gsapDragCommit.test.ts +75 -164
  50. package/src/hooks/gsapDragCommit.ts +2 -105
  51. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  52. package/src/hooks/gsapEditOutcome.ts +22 -2
  53. package/src/hooks/gsapGroupPreflight.ts +34 -0
  54. package/src/hooks/gsapMutationClient.ts +4 -3
  55. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  56. package/src/hooks/gsapParsedTween.test.ts +62 -0
  57. package/src/hooks/gsapParsedTween.ts +243 -0
  58. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  59. package/src/hooks/gsapPositionDetection.ts +52 -33
  60. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  61. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  62. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  63. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  64. package/src/hooks/gsapResizeIntercept.ts +79 -169
  65. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  66. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  67. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  68. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  69. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  70. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  71. package/src/hooks/gsapScriptCommitTypes.ts +9 -1
  72. package/src/hooks/gsapShared.ts +7 -0
  73. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  74. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  75. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  76. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  77. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  78. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  79. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  80. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  81. package/src/hooks/newTweenTarget.test.ts +41 -76
  82. package/src/hooks/plainRotation.ts +5 -1
  83. package/src/hooks/timelineTimingSync.test.ts +1 -0
  84. package/src/hooks/timelineTimingSync.ts +2 -1
  85. package/src/hooks/useAskAgentModal.ts +2 -1
  86. package/src/hooks/useBlockCatalog.ts +2 -1
  87. package/src/hooks/useCaptionDetection.ts +2 -1
  88. package/src/hooks/useCompositionContentLoader.ts +2 -1
  89. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  90. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  91. package/src/hooks/useDomEditSession.test.tsx +30 -1
  92. package/src/hooks/useDomEditSession.ts +10 -0
  93. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  94. package/src/hooks/useDomEditTextCommits.ts +8 -2
  95. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  96. package/src/hooks/useDomGeometryCommit.ts +7 -9
  97. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  98. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  99. package/src/hooks/useEditHistoryActions.ts +16 -5
  100. package/src/hooks/useElementLifecycleOps.ts +2 -1
  101. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  102. package/src/hooks/useEnableKeyframes.ts +2 -1
  103. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  104. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  105. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  106. package/src/hooks/useFileManager.ts +14 -10
  107. package/src/hooks/useFileTree.ts +3 -2
  108. package/src/hooks/useFrameCapture.ts +5 -1
  109. package/src/hooks/useFreezeFrame.test.ts +39 -3
  110. package/src/hooks/useFreezeFrame.ts +7 -3
  111. package/src/hooks/useGroupCommits.ts +2 -1
  112. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  113. package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
  114. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  115. package/src/hooks/useGsapAwareEditing.ts +17 -8
  116. package/src/hooks/useGsapAwareGroupMove.ts +18 -18
  117. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  118. package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
  119. package/src/hooks/useGsapScriptCommits.ts +21 -17
  120. package/src/hooks/useLintModal.ts +2 -1
  121. package/src/hooks/usePersistentEditHistory.test.ts +302 -3
  122. package/src/hooks/usePersistentEditHistory.ts +50 -17
  123. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  124. package/src/hooks/usePreviewPersistence.ts +13 -11
  125. package/src/hooks/useProjectFileWriter.ts +20 -19
  126. package/src/hooks/useRemoveBackground.ts +2 -1
  127. package/src/hooks/useSdkSession.ts +2 -1
  128. package/src/hooks/useServerConnection.ts +4 -3
  129. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  130. package/src/hooks/useThumbnailStill.test.ts +100 -0
  131. package/src/hooks/useThumbnailStill.ts +37 -0
  132. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  133. package/src/index.ts +4 -0
  134. package/src/player/components/AudioWaveform.tsx +3 -2
  135. package/src/player/components/CompositionThumbnail.tsx +2 -1
  136. package/src/player/components/clipMenuNormalize.ts +2 -1
  137. package/src/player/components/clipPeakMap.ts +2 -1
  138. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  139. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  140. package/src/player/lib/thumbnailScheduler.ts +10 -2
  141. package/src/player/sceneSwap.ts +2 -1
  142. package/src/telemetry/client.ts +2 -1
  143. package/src/utils/blockInstaller.ts +2 -1
  144. package/src/utils/elementGsap.test.ts +9 -0
  145. package/src/utils/elementGsap.ts +7 -0
  146. package/src/utils/gsapSoftReload.test.ts +39 -0
  147. package/src/utils/gsapSoftReload.ts +9 -14
  148. package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
  149. package/src/utils/gsapUndoRestore.ts +3 -6
  150. package/src/utils/previewPrefetch.ts +2 -1
  151. package/src/utils/razorSplitTransaction.ts +12 -8
  152. package/src/utils/softReloadTargets.ts +5 -6
  153. package/src/utils/studioApiFetch.test.ts +75 -0
  154. package/src/utils/studioApiFetch.ts +13 -0
  155. package/src/utils/studioFileHistory.ts +5 -1
  156. package/src/utils/studioHelpers.ts +7 -1
  157. package/src/utils/studioPendingEdits.test.ts +23 -6
  158. package/src/utils/studioPendingEdits.ts +32 -8
  159. package/src/utils/studioTelemetry.ts +2 -1
  160. package/src/utils/timelineCompositionInsert.ts +3 -2
  161. package/src/webmcp/StudioAgentTools.tsx +2 -1
  162. package/dist/chunk-TXZMMOMA.js.map +0 -1
  163. /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -2,17 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server";
3
3
  import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
4
4
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
5
-
6
- interface RecordEditInput {
7
- label: string;
8
- coalesceKey?: string;
9
- coalesceMs?: number;
10
- files: Record<string, { before: string; after: string }>;
11
- }
5
+ import { studioApiFetch } from "../utils/studioApiFetch";
6
+ import type { RecordEditInput } from "../utils/studioFileHistory";
12
7
 
13
8
  interface ApplyCallbacks {
14
9
  readFile: (path: string) => Promise<string>;
15
10
  serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
11
+ claimedAfter?: number;
16
12
  }
17
13
 
18
14
  export interface UsePersistentEditHistoryOptions {
@@ -53,6 +49,8 @@ function createOwnHistory() {
53
49
  const own = new Map<string, OwnFiles>();
54
50
  let next: Record<"undo" | "redo", NextStep | null> | null = null;
55
51
  let changes = 0;
52
+ let claimed = { count: 0, id: "" };
53
+ const undoneIds = new Set<string>();
56
54
  const remember = (id: string, files: OwnFiles) => {
57
55
  const known = own.get(id) ?? {};
58
56
  for (const [path, { before, after }] of Object.entries(files)) {
@@ -73,7 +71,16 @@ function createOwnHistory() {
73
71
  if (seen === changes) next = { undo: view.back, redo: view.forward };
74
72
  },
75
73
  changes: () => changes,
74
+ claimCount: () => claimed.count,
75
+ claimedAfter: (count: number) => (claimed.count > count && claimed.id ? claimed.id : null),
76
+ targetFor: (id: string | null, pressedAt: number) =>
77
+ !id || !undoneIds.has(id) ? id : claimed.count > pressedAt ? false : null,
78
+ noteClaim: (id: string) => {
79
+ claimed = { count: claimed.count + 1, id };
80
+ },
76
81
  stepped: (entry: { id: string; undoes?: string }) => {
82
+ if (entry.undoes) undoneIds.add(entry.undoes);
83
+ if (undoneIds.size > OWN_ENTRIES_KEPT) undoneIds.delete(undoneIds.values().next().value!);
77
84
  const undone = entry.undoes ? own.get(entry.undoes) : undefined;
78
85
  if (!undone) return;
79
86
  const swapped = Object.entries(undone).map(([path, f]) => [
@@ -102,6 +109,7 @@ function createOwnHistory() {
102
109
  },
103
110
  clear: () => {
104
111
  own.clear();
112
+ undoneIds.clear();
105
113
  next = null;
106
114
  changes += 1;
107
115
  },
@@ -113,12 +121,20 @@ function historyUrl(projectId: string, path = ""): string {
113
121
  return `/api/projects/${encodeURIComponent(projectId)}/history${path}`;
114
122
  }
115
123
 
124
+ async function cantUndo(paths: string[] | undefined): Promise<ApplyResult> {
125
+ return { ok: false, reason: "content-mismatch", paths: paths ?? [] };
126
+ }
127
+
128
+ function unionPaths(...lists: Array<readonly string[] | undefined>): string[] {
129
+ return [...new Set(lists.flatMap((list) => list ?? []))];
130
+ }
131
+
116
132
  async function post(
117
133
  url: string,
118
134
  body: object,
119
135
  headers: Record<string, string> = {},
120
136
  ): Promise<{ ok: true; body: unknown } | { ok: false; status: number; error: string }> {
121
- const response = await fetch(url, {
137
+ const response = await studioApiFetch(url, {
122
138
  method: "POST",
123
139
  headers: { "Content-Type": "application/json", ...headers },
124
140
  body: JSON.stringify(body),
@@ -194,9 +210,12 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
194
210
  const refresh = useCallback(async () => {
195
211
  if (!projectId) return;
196
212
  const seen = own.changes();
197
- const response = await fetch(historyUrl(projectId)).catch(() => null);
198
- const next = response?.ok ? ((await response.json()) as HistoryView) : EMPTY;
213
+ const response = await studioApiFetch(historyUrl(projectId)).catch(() => null);
214
+ const next = response?.ok
215
+ ? ((await response.json().catch(() => null)) as HistoryView | null)
216
+ : EMPTY;
199
217
  if (projectIdRef.current !== projectId) return;
218
+ if (!next) return console.error("The history's reply was unreadable.");
200
219
  setView(next);
201
220
  own.offered(seen, next);
202
221
  }, [projectId, own]);
@@ -210,9 +229,9 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
210
229
  }, [refresh, own]);
211
230
 
212
231
  const recordEdit = useCallback(
213
- async ({ label, coalesceKey, coalesceMs, files }: RecordEditInput) => {
232
+ async ({ label, coalesceKey, coalesceMs, files, created = [] }: RecordEditInput) => {
214
233
  if (!projectId) return;
215
- const paths = Object.keys(files);
234
+ const paths = [...Object.keys(files), ...created];
216
235
  own.overtake();
217
236
  const reply = await post(historyUrl(projectId, "/claim"), {
218
237
  label,
@@ -221,7 +240,10 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
221
240
  ...(coalesceKey && { coalesceKey, idleMs: coalesceMs ?? DEFAULT_COALESCE_MS }),
222
241
  });
223
242
  const claimed = claimHeld(reply, label);
224
- if (claimed) own.remember(claimed, files);
243
+ if (claimed) {
244
+ own.remember(claimed, files);
245
+ own.noteClaim(claimed);
246
+ }
225
247
  heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
226
248
  await refresh();
227
249
  },
@@ -231,14 +253,20 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
231
253
  const step = useCallback(
232
254
  async (direction: "undo" | "redo", callbacks: ApplyCallbacks): Promise<ApplyResult> => {
233
255
  if (!projectId) return { ok: false, reason: "empty" };
256
+ const candidate =
257
+ direction === "undo" && callbacks.claimedAfter !== undefined
258
+ ? own.claimedAfter(callbacks.claimedAfter)
259
+ : null;
234
260
  const next = direction === "undo" ? view.back : view.forward;
235
- const paths = [...new Set([...(next?.paths ?? []), ...(heldClaimRef.current?.paths ?? [])])];
261
+ const stepPaths = candidate ? Object.keys(own.afterOf(candidate)) : next?.paths;
262
+ const paths = unionPaths(stepPaths, heldClaimRef.current?.paths);
263
+ const pressedAt = own.claimCount();
236
264
  own.overtake();
237
- const run = async (): Promise<ApplyResult> => {
265
+ const attempt = async (target: string | null): Promise<ApplyResult> => {
238
266
  const previous = await readAll(paths, callbacks.readFile);
239
267
  const posted = await post(
240
- historyUrl(projectId, "/step"),
241
- { direction: direction === "undo" ? "back" : "forward" },
268
+ historyUrl(projectId, target ? "/undo" : "/step"),
269
+ target ? { entryId: target } : { direction: direction === "undo" ? "back" : "forward" },
242
270
  studioWriteHeaders(),
243
271
  );
244
272
  heldClaimRef.current = null;
@@ -266,6 +294,10 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
266
294
  ),
267
295
  };
268
296
  };
297
+ const run = () => {
298
+ const target = own.targetFor(candidate, pressedAt);
299
+ return target === false ? cantUndo(stepPaths) : attempt(target);
300
+ };
269
301
  return callbacks.serialize ? callbacks.serialize(paths, run) : run();
270
302
  },
271
303
  [projectId, view, refresh, own],
@@ -301,6 +333,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
301
333
  undo,
302
334
  redo,
303
335
  predict: own.predict,
336
+ claims: own.claimCount,
304
337
  noteOutsideChange,
305
338
  };
306
339
  }
@@ -79,12 +79,13 @@ describe("undo that re-runs the top-level script over an element of a nested com
79
79
  };
80
80
  }
81
81
 
82
- async function undo(fileOk: boolean) {
82
+ async function undo(fileOk: boolean, projectId: string | null = "p1") {
83
83
  const { iframe, nwid } = nestedPreview();
84
84
  const reloadPreview = vi.fn();
85
85
  const fetch = vi.fn(async () => ({ ok: fileOk, json: async () => ({ content: SUB }) }));
86
86
  vi.stubGlobal("fetch", fetch);
87
- usePlayerStore.getState().beginTimelineSession("p1");
87
+ if (projectId) usePlayerStore.getState().beginTimelineSession(projectId);
88
+ else usePlayerStore.setState({ timelineProjectId: null });
88
89
  let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
89
90
  null;
90
91
  function Harness() {
@@ -109,7 +110,9 @@ describe("undo that re-runs the top-level script over an element of a nested com
109
110
  it("restores the element from its own composition file", async () => {
110
111
  const { nwid, reloadPreview, fetch } = await undo(true);
111
112
 
112
- expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
113
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"), {
114
+ credentials: "omit",
115
+ });
113
116
  expect(reloadPreview).not.toHaveBeenCalled();
114
117
  expect(nwid.getAttribute("style")).toBe("left: 40px;");
115
118
  });
@@ -120,4 +123,12 @@ describe("undo that re-runs the top-level script over an element of a nested com
120
123
  expect(reloadPreview).toHaveBeenCalledTimes(1);
121
124
  expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
122
125
  });
126
+
127
+ it("reloads the preview in full when no project is open to read that file from", async () => {
128
+ const { nwid, reloadPreview, fetch } = await undo(true, null);
129
+
130
+ expect(fetch).not.toHaveBeenCalled();
131
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
132
+ expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
133
+ });
123
134
  });
@@ -163,8 +163,9 @@ export function usePreviewPersistence({
163
163
  }, [drainPendingDomEditSaves]);
164
164
 
165
165
  const settlePendingEdits = useCallback(async (): Promise<void> => {
166
- await flushStudioPendingEdits();
167
- await domEditSaveQueueRef.current?.waitForIdle();
166
+ const queued = domEditSaveQueueRef.current?.waitForIdle();
167
+ await flushStudioPendingEdits({ onlyCurrent: true });
168
+ await queued;
168
169
  }, []);
169
170
 
170
171
  const resetDomEditSaveQueueBreaker = useCallback(() => {
@@ -209,14 +210,15 @@ export function usePreviewPersistence({
209
210
  // multi-file, sub-comp, or a permanent soft-reload failure.
210
211
  const projectId = usePlayerStore.getState().timelineProjectId;
211
212
  const nestedFiles = await settleNestedReads(
212
- projectId
213
- ? readUndoNestedFiles(
214
- previewIframeRef.current,
215
- activeCompPathRef.current,
216
- restore.files,
217
- (path) => readProjectFileContent(projectId, path),
218
- )
219
- : null,
213
+ readUndoNestedFiles(
214
+ previewIframeRef.current,
215
+ activeCompPathRef.current,
216
+ restore.files,
217
+ (path) =>
218
+ projectId
219
+ ? readProjectFileContent(projectId, path)
220
+ : Promise.reject(new Error("No project is open to read nested files from.")),
221
+ ),
220
222
  );
221
223
  const strategy = applyUndoRestoreToPreview(
222
224
  previewIframeRef.current,
@@ -258,7 +260,7 @@ export function usePreviewPersistence({
258
260
 
259
261
  // ── Migrate legacy studio-motion.json ──
260
262
  // Projects that used the old JSON-file approach may still have a populated
261
- // `.hyperframes/studio-motion.json`. The studio no longer reads from it, but
263
+ // studio-motion.json in the project's .hyperframes folder. The studio no longer reads it, but
262
264
  // the legacy render-script injection in `preview.ts` / `vite.studioMotion.ts`
263
265
  // could still fire alongside the new seek-reapply runtime. Empty the file so
264
266
  // the legacy codepath no-ops.
@@ -6,11 +6,22 @@ import {
6
6
  StudioSaveNetworkError,
7
7
  } from "../utils/studioSaveDiagnostics";
8
8
  import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
9
+ import { studioApiFetch } from "../utils/studioApiFetch";
9
10
 
10
11
  export interface UseProjectFileWriterOptions {
11
12
  projectId: string | null;
12
13
  }
13
14
 
15
+ async function fetchProjectFile(projectId: string | null, path: string, query: string) {
16
+ if (!projectId) throw new Error("No active project");
17
+ const response = await studioApiFetch(
18
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}${query}`,
19
+ );
20
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
21
+ const data = (await response.json()) as { content?: string; version?: string };
22
+ return { content: data.content, version: data.version ?? response.headers.get("etag") };
23
+ }
24
+
14
25
  // The etag-guarded project-file read/write pair every Studio save path
15
26
  // shares. A host mounting hand editing outside Studio needs only this, not
16
27
  // the full file manager (file tree, editor tabs, uploads, font assets).
@@ -30,29 +41,19 @@ export function useProjectFileWriter({ projectId }: UseProjectFileWriterOptions)
30
41
 
31
42
  const readProjectFile = useCallback(
32
43
  async (path: string): Promise<string> => {
33
- if (!projectId) throw new Error("No active project");
34
- const response = await fetch(
35
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
36
- );
37
- if (!response.ok) throw new Error(`Failed to read ${path}`);
38
- const data = (await response.json()) as { content?: string; version?: string };
39
- if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
40
- fileVersions.set(path, data.version ?? response.headers.get("etag"));
41
- return data.content;
44
+ const { content, version } = await fetchProjectFile(projectId, path, "");
45
+ if (typeof content !== "string") throw new Error(`Missing file contents for ${path}`);
46
+ fileVersions.set(path, version);
47
+ return content;
42
48
  },
43
49
  [fileVersions, projectId],
44
50
  );
45
51
 
46
52
  const readOptionalProjectFile = useCallback(
47
53
  async (path: string): Promise<string> => {
48
- if (!projectId) throw new Error("No active project");
49
- const response = await fetch(
50
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
51
- );
52
- if (!response.ok) throw new Error(`Failed to read ${path}`);
53
- const data = (await response.json()) as { content?: string; version?: string };
54
- fileVersions.set(path, data.version ?? response.headers.get("etag"));
55
- return typeof data.content === "string" ? data.content : "";
54
+ const { content, version } = await fetchProjectFile(projectId, path, "?optional=1");
55
+ fileVersions.set(path, version);
56
+ return typeof content === "string" ? content : "";
56
57
  },
57
58
  [fileVersions, projectId],
58
59
  );
@@ -63,7 +64,7 @@ export function useProjectFileWriter({ projectId }: UseProjectFileWriterOptions)
63
64
  const writeProjectId = projectId;
64
65
  let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
65
66
  if (expectedVersion === undefined) {
66
- const preflight = await fetch(
67
+ const preflight = await studioApiFetch(
67
68
  `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
68
69
  );
69
70
  if (preflight.ok) {
@@ -86,7 +87,7 @@ export function useProjectFileWriter({ projectId }: UseProjectFileWriterOptions)
86
87
  // the retry can produce a second filesystem receipt that must be suppressed independently.
87
88
  let response: Response;
88
89
  try {
89
- response = await fetch(
90
+ response = await studioApiFetch(
90
91
  `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
91
92
  {
92
93
  method: "PUT",
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import { waitForMediaJob } from "../components/studioMediaJobs";
3
3
  import type { BackgroundRemovalProgress } from "../components/editor/propertyPanelTypes";
4
+ import { studioApiFetch } from "../utils/studioApiFetch";
4
5
 
5
6
  interface RemoveBackgroundOptions {
6
7
  createBackgroundPlate?: boolean;
@@ -31,7 +32,7 @@ export function useRemoveBackground(
31
32
  return useCallback(
32
33
  // fallow-ignore-next-line complexity
33
34
  async (inputPath: string, options: RemoveBackgroundOptions) => {
34
- const response = await fetch(
35
+ const response = await studioApiFetch(
35
36
  `/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
36
37
  {
37
38
  method: "POST",
@@ -6,6 +6,7 @@ import { trackStudioEvent } from "../utils/studioTelemetry";
6
6
  import type { PublishSdkSession } from "../utils/sdkCutover";
7
7
  import { addExternalFileReloadListener } from "./externalFileReloadBus";
8
8
  import { whenPreviewBooted } from "../player/store/playerStore";
9
+ import { studioApiFetch } from "../utils/studioApiFetch";
9
10
 
10
11
  /**
11
12
  * Why an optional project-file read produced no usable content. `stage: "read"`
@@ -194,7 +195,7 @@ async function readProjectFileOptional(
194
195
  let res: Response;
195
196
  const fetchStarted = performance.now();
196
197
  try {
197
- res = await fetch(
198
+ res = await studioApiFetch(
198
199
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
199
200
  );
200
201
  } catch {
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
2
2
  import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting";
3
3
  import { useMountEffect } from "./useMountEffect";
4
4
  import { trackStudioEvent } from "../utils/studioTelemetry";
5
+ import { studioApiFetch } from "../utils/studioApiFetch";
5
6
 
6
7
  interface ServerConnectionState {
7
8
  projectId: string | null;
@@ -37,7 +38,7 @@ type ProjectHashVerdict = {
37
38
 
38
39
  async function resolveHashProject(id: string): Promise<ProjectHashVerdict> {
39
40
  try {
40
- const res = await fetch(`/api/projects/${encodeURIComponent(id)}`);
41
+ const res = await studioApiFetch(`/api/projects/${encodeURIComponent(id)}`);
41
42
  if (res.ok) return { outcome: "ok", status: res.status };
42
43
  return { outcome: res.status === 404 ? "missing" : "unknown", status: res.status };
43
44
  } catch {
@@ -72,7 +73,7 @@ function replaceProjectHash(projectId: string): void {
72
73
  */
73
74
  async function firstProjectId(): Promise<string | null> {
74
75
  try {
75
- const res = await fetch("/api/projects");
76
+ const res = await studioApiFetch("/api/projects");
76
77
  const data = (await res.json()) as { projects?: Array<{ id?: string }> };
77
78
  return data.projects?.[0]?.id ?? null;
78
79
  } catch {
@@ -107,7 +108,7 @@ export function useServerConnection(): ServerConnectionState {
107
108
  }
108
109
 
109
110
  function tryConnect() {
110
- fetch("/api/projects")
111
+ studioApiFetch("/api/projects")
111
112
  .then((r) => r.json())
112
113
  .then(async (data) => {
113
114
  if (cancelled) return;
@@ -3,6 +3,7 @@ import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import { usePlayerStore } from "../player";
4
4
  import { buildStudioSelectionSnapshot } from "../utils/studioSelectionSnapshot";
5
5
  import { trackStudioEvent } from "../utils/studioTelemetry";
6
+ import { studioApiFetch } from "../utils/studioApiFetch";
6
7
 
7
8
  interface UseStudioSelectionPublisherParams {
8
9
  projectId: string | null;
@@ -26,7 +27,7 @@ function reportSelectionPublishError(error: unknown): void {
26
27
  }
27
28
 
28
29
  function putSelection(projectId: string, selection: unknown, signal?: AbortSignal): Promise<void> {
29
- return fetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, {
30
+ return studioApiFetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, {
30
31
  method: "PUT",
31
32
  headers: { "Content-Type": "application/json" },
32
33
  body: JSON.stringify({ selection }),
@@ -0,0 +1,100 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { thumbnailScheduler } from "../player/lib/thumbnailScheduler";
7
+ // By package name, as a host imports it: the test fails if the export goes.
8
+ import { useThumbnailStill, type ThumbnailStill } from "@hyperframes/studio";
9
+
10
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
11
+
12
+ class MockImage {
13
+ onload: (() => void) | null = null;
14
+ onerror: (() => void) | null = null;
15
+ naturalWidth = 320;
16
+ naturalHeight = 180;
17
+ set src(_value: string) {
18
+ queueMicrotask(() => this.onload?.());
19
+ }
20
+ }
21
+
22
+ const URL_ONE = "http://localhost:3000/api/projects/demo/thumbnail/index.html?t=1.00&revision=1";
23
+ const originalImage = globalThis.Image;
24
+ const originalFetch = globalThis.fetch;
25
+ const originalCreate = URL.createObjectURL;
26
+ const originalRevoke = URL.revokeObjectURL;
27
+ let host: HTMLDivElement;
28
+ let root: Root | null = null;
29
+ let shown: ThumbnailStill | null = null;
30
+
31
+ function Still({ url }: { url: string | null }) {
32
+ shown = useThumbnailStill(url, "demo");
33
+ return null;
34
+ }
35
+
36
+ const mount = (url: string | null) =>
37
+ act(async () => {
38
+ root ??= createRoot(host);
39
+ root.render(React.createElement(Still, { url }));
40
+ });
41
+
42
+ beforeEach(() => {
43
+ globalThis.Image = MockImage as unknown as typeof Image;
44
+ globalThis.fetch = vi.fn(async () => new Response(new Blob(["still"]), { status: 200 }));
45
+ URL.createObjectURL = vi.fn(() => "blob:still");
46
+ URL.revokeObjectURL = vi.fn();
47
+ shown = null;
48
+ host = document.createElement("div");
49
+ document.body.append(host);
50
+ });
51
+
52
+ afterEach(() => {
53
+ act(() => root?.unmount());
54
+ root = null;
55
+ thumbnailScheduler.setPreviewReloading(false);
56
+ thumbnailScheduler.invalidateProject("demo");
57
+ globalThis.Image = originalImage;
58
+ globalThis.fetch = originalFetch;
59
+ URL.createObjectURL = originalCreate;
60
+ URL.revokeObjectURL = originalRevoke;
61
+ document.body.replaceChildren();
62
+ });
63
+
64
+ describe("useThumbnailStill", () => {
65
+ it("hands back the still's object URL once the scheduler has loaded it", async () => {
66
+ await mount(URL_ONE);
67
+ expect(globalThis.fetch).toHaveBeenCalledWith(URL_ONE, expect.anything());
68
+ await vi.waitFor(() => expect(shown).toEqual({ status: "ready", url: "blob:still" }));
69
+ });
70
+
71
+ it("hands back the same object while nothing changes", async () => {
72
+ await mount(URL_ONE);
73
+ await vi.waitFor(() => expect(shown).toEqual({ status: "ready", url: "blob:still" }));
74
+ const first = shown;
75
+ await mount(URL_ONE);
76
+ expect(shown).toBe(first);
77
+ });
78
+
79
+ it("asks for nothing without a url", async () => {
80
+ await mount(null);
81
+ expect(globalThis.fetch).not.toHaveBeenCalled();
82
+ expect(shown).toEqual({ status: "loading" });
83
+ });
84
+
85
+ it("says failed when the still cannot load, so a host can fall back", async () => {
86
+ globalThis.fetch = vi.fn(async () => new Response("", { status: 500 }));
87
+ await mount(URL_ONE);
88
+ await vi.waitFor(() => expect(shown).toEqual({ status: "failed" }));
89
+ });
90
+
91
+ it("holds the fetch while a preview reloads, and runs it once the reload is over", async () => {
92
+ thumbnailScheduler.setPreviewReloading(true);
93
+ await mount(URL_ONE);
94
+ expect(globalThis.fetch).not.toHaveBeenCalled();
95
+ expect(shown).toEqual({ status: "loading" });
96
+ await act(async () => thumbnailScheduler.setPreviewReloading(false));
97
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
98
+ await vi.waitFor(() => expect(shown).toEqual({ status: "ready", url: "blob:still" }));
99
+ });
100
+ });
@@ -0,0 +1,37 @@
1
+ import { useMemo } from "react";
2
+ import { compositionThumbnailRequest } from "../player/components/CompositionThumbnail";
3
+ import type { ThumbnailPriority } from "../player/lib/thumbnailScheduler";
4
+ import { useThumbnailLease } from "./useThumbnailLease";
5
+
6
+ export interface UseThumbnailStillOptions {
7
+ sessionEpoch?: number;
8
+ priority?: ThumbnailPriority;
9
+ }
10
+
11
+ export type ThumbnailStill =
12
+ | { status: "loading" }
13
+ | { status: "ready"; url: string }
14
+ | { status: "failed" };
15
+
16
+ const LOADING: ThumbnailStill = Object.freeze({ status: "loading" });
17
+ const FAILED: ThumbnailStill = Object.freeze({ status: "failed" });
18
+
19
+ /** A host picture's server still through the thumbnail scheduler, so it yields to a preview reload. */
20
+ export function useThumbnailStill(
21
+ url: string | null,
22
+ projectId: string,
23
+ { sessionEpoch = 0, priority = "visible" }: UseThumbnailStillOptions = {},
24
+ ): ThumbnailStill {
25
+ const request = useMemo(
26
+ () => (url ? compositionThumbnailRequest(url, projectId, { sessionEpoch, priority }) : null),
27
+ [priority, projectId, sessionEpoch, url],
28
+ );
29
+ const snapshot = useThumbnailLease(request);
30
+ const ready =
31
+ snapshot.status === "ready" && snapshot.value.kind === "image" ? snapshot.value.url : null;
32
+ return useMemo(
33
+ () =>
34
+ ready ? { status: "ready", url: ready } : snapshot.status === "error" ? FAILED : LOADING,
35
+ [ready, snapshot.status],
36
+ );
37
+ }
@@ -21,6 +21,7 @@ import type {
21
21
  TimelineGroupCommitOptions,
22
22
  TimelineGroupMoveChange,
23
23
  } from "./useTimelineGroupEditing";
24
+ import { studioApiFetch } from "../utils/studioApiFetch";
24
25
 
25
26
  /** Apply already-resolved ripple changes to the surviving elements for the
26
27
  * optimistic store update after a delete. Pure — no IO. */
@@ -130,7 +131,7 @@ export function useTimelineDeleteOps({
130
131
  }
131
132
  return patchTarget;
132
133
  });
133
- const removeResponse = await fetch(
134
+ const removeResponse = await studioApiFetch(
134
135
  buildProjectApiPath(
135
136
  pid,
136
137
  `/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
package/src/index.ts CHANGED
@@ -76,6 +76,8 @@ export type { AudioWaveformProps } from "./player/components/AudioWaveform";
76
76
  export { ImageThumbnail } from "./player/components/ImageThumbnail";
77
77
  export type { ImageThumbnailProps } from "./player/components/ImageThumbnail";
78
78
  export { useRenderClipContent } from "./hooks/useRenderClipContent";
79
+ export { useThumbnailStill } from "./hooks/useThumbnailStill";
80
+ export type { ThumbnailStill, UseThumbnailStillOptions } from "./hooks/useThumbnailStill";
79
81
  export type { UseRenderClipContentOptions } from "./hooks/useRenderClipContent";
80
82
  export type { ThumbnailPriority } from "./player/lib/thumbnailScheduler";
81
83
  export type {
@@ -208,6 +210,8 @@ export type { DomEditZOrder } from "./components/editor/useDomEditZOrder";
208
210
  export type { ZOrderAction } from "./components/editor/canvasContextMenuZOrder";
209
211
  export type { DomEditCapabilities, DomEditSelection } from "./components/editor/domEditingTypes";
210
212
  export { useDomStyleCommit } from "./hooks/useDomStyleCommit";
213
+ export { ColorField } from "./components/editor/propertyPanelColor";
214
+ export { GradientField } from "./components/editor/propertyPanelFill";
211
215
  export type { UseDomStyleCommitOptions } from "./hooks/useDomStyleCommit";
212
216
  export type { DomEditCommitDeclineReason, DomEditCommitOutcome } from "./hooks/domEditCommitRunner";
213
217
  export { resolveDomEditSelection } from "./components/editor/domEditingLayers";
@@ -5,6 +5,7 @@ import { useThumbnailLease } from "../../hooks/useThumbnailLease";
5
5
  import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
6
6
  import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
7
7
  import { ClipFadesContext, type ClipFadeShape } from "./TimelineClipFades";
8
+ import { studioApiFetch } from "../../utils/studioApiFetch";
8
9
 
9
10
  export interface AudioWaveformProps {
10
11
  audioUrl: string;
@@ -124,7 +125,7 @@ async function loadWaveform(
124
125
  }
125
126
 
126
127
  async function fetchWaveformPeaks(url: string, signal: AbortSignal): Promise<number[]> {
127
- const response = await fetch(url, { signal });
128
+ const response = await studioApiFetch(url, { signal });
128
129
  if (!response.ok) throw new Error(`Waveform request failed (${response.status})`);
129
130
  const data: unknown = await response.json();
130
131
  if (
@@ -140,7 +141,7 @@ async function fetchWaveformPeaks(url: string, signal: AbortSignal): Promise<num
140
141
  }
141
142
 
142
143
  async function decodeWaveformPeaks(url: string, signal: AbortSignal): Promise<number[]> {
143
- const response = await fetch(url, { signal });
144
+ const response = await studioApiFetch(url, { signal });
144
145
  if (!response.ok) throw new Error(`Audio request failed (${response.status})`);
145
146
  const buffer = await response.arrayBuffer();
146
147
  if (signal.aborted) throw new DOMException("Aborted", "AbortError");
@@ -8,6 +8,7 @@ import {
8
8
  } from "../lib/thumbnailScheduler";
9
9
  import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
10
10
  import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
11
+ import { studioApiFetch } from "../../utils/studioApiFetch";
11
12
 
12
13
  interface CompositionThumbnailProps {
13
14
  previewUrl: string;
@@ -106,7 +107,7 @@ export function compositionThumbnailRequest(
106
107
  }
107
108
 
108
109
  async function loadCompositionImage(url: string, signal: AbortSignal) {
109
- const response = await fetch(url, { signal });
110
+ const response = await studioApiFetch(url, { signal });
110
111
  if (!response.ok) throw new Error(`Composition thumbnail failed (${response.status})`);
111
112
  const blob = await response.blob();
112
113
  if (signal.aborted) throw new DOMException("Aborted", "AbortError");
@@ -4,6 +4,7 @@ import { resolveMediaPreviewUrl } from "./thumbnailUtils";
4
4
  import type { TimelineElement } from "../store/timelineElement";
5
5
  import { parseAutomation, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
6
6
  import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
7
+ import { studioApiFetch } from "../../utils/studioApiFetch";
7
8
 
8
9
  export interface NormalizePlan {
9
10
  targetLufs: number;
@@ -74,7 +75,7 @@ function isNormalizePlan(value: unknown): value is NormalizePlan {
74
75
  export async function requestNormalizePlan(
75
76
  projectId: string,
76
77
  el: TimelineElement,
77
- fetchImpl: typeof fetch = fetch,
78
+ fetchImpl: typeof studioApiFetch = studioApiFetch,
78
79
  ): Promise<NormalizePlan> {
79
80
  const origin = window.location.origin;
80
81
  const relative = resolvePreviewRelative(
@@ -1,4 +1,5 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
+ import { studioApiFetch } from "../../utils/studioApiFetch";
2
3
  import { resolvePreviewRelative } from "../../utils/previewRelativePath";
3
4
  import type { TimelineElement } from "../store/timelineElement";
4
5
  import { clipSourcePeak, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
@@ -10,7 +11,7 @@ const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
10
11
  export function loadPeakMap(url: string): Promise<PeakMap | null> {
11
12
  const pending = peakMapRequests.get(url);
12
13
  if (pending) return pending;
13
- const request = fetch(url)
14
+ const request = studioApiFetch(url)
14
15
  .then((res) => (res.ok ? res.json() : null))
15
16
  .then((body: unknown) => (isPeakMap(body) ? body : null))
16
17
  .catch(() => null);