@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
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
4
4
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
- import { afterEach, beforeEach, expect, it, vi } from "vitest";
7
+ import { afterEach, beforeEach, expect, it, vi, type Mock } from "vitest";
8
8
  import {
9
9
  createStudioApi,
10
10
  openProjectHistory,
@@ -168,6 +168,20 @@ it("an undo pressed while a save is still running waits for the server instead o
168
168
  expect(s.box()).toBe("10px");
169
169
  });
170
170
 
171
+ it("an undo waits for a save queued before its key, not one queued after", async () => {
172
+ const s = await studio();
173
+ await s.edit();
174
+ let land!: () => void;
175
+ void s.persistence().queueDomEditSave(() => new Promise<void>((resolve) => (land = resolve)));
176
+ await vi.waitFor(() => expect(land).toBeTypeOf("function"));
177
+
178
+ const undone = s.actions().undo();
179
+ void s.persistence().queueDomEditSave(() => new Promise<void>(() => {}));
180
+ land();
181
+ await act(() => undone);
182
+ expect(s.file()).toBe(BEFORE);
183
+ });
184
+
171
185
  it("an undo after an outside write ends with the preview showing the file the server restored", async () => {
172
186
  const s = await studio();
173
187
  await s.edit();
@@ -202,10 +216,8 @@ function Nudge({ target, save }: { target: HTMLElement; save: () => Promise<void
202
216
  return null;
203
217
  }
204
218
 
205
- it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
206
- const s = await studio();
207
- await s.edit();
208
- resetNudgeKeys();
219
+ /** A nudge save that waits for `finish()`, then writes and records the nudge as the real save does. */
220
+ function queuedNudgeSave(s: Awaited<ReturnType<typeof studio>>) {
209
221
  let finish!: () => void;
210
222
  const save = vi.fn(async () => {
211
223
  await new Promise<void>((resolve) => (finish = resolve));
@@ -215,6 +227,24 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
215
227
  files: { "index.html": { before: AFTER, after: NUDGED } },
216
228
  });
217
229
  });
230
+ return { save, finish: () => finish() };
231
+ }
232
+
233
+ /** Mounts the nudge, presses ArrowRight once and waits for its save; returns the nudge's translate reader. */
234
+ async function pressNudge(s: Awaited<ReturnType<typeof studio>>, save: Mock<() => Promise<void>>) {
235
+ s.mount(createElement(Nudge, { target: s.element("box"), save }));
236
+ act(() => {
237
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
238
+ });
239
+ await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
240
+ return () => s.element("box").style.getPropertyValue("translate");
241
+ }
242
+
243
+ it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
244
+ const s = await studio();
245
+ await s.edit();
246
+ resetNudgeKeys();
247
+ const { save, finish } = queuedNudgeSave(s);
218
248
  s.mount(createElement(Nudge, { target: s.element("box"), save }));
219
249
  act(() => {
220
250
  window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
@@ -240,21 +270,8 @@ it("an undo pressed while a nudge's save is queued shows the nudge undone in the
240
270
  const s = await studio();
241
271
  await s.edit();
242
272
  resetNudgeKeys();
243
- let finish!: () => void;
244
- const save = vi.fn(async () => {
245
- await new Promise<void>((resolve) => (finish = resolve));
246
- writeFileSync(s.path, NUDGED);
247
- await s.history().recordEdit({
248
- label: "Move layer",
249
- files: { "index.html": { before: AFTER, after: NUDGED } },
250
- });
251
- });
252
- s.mount(createElement(Nudge, { target: s.element("box"), save }));
253
- act(() => {
254
- window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
255
- });
256
- await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
257
- const nudge = () => s.element("box").style.getPropertyValue("translate");
273
+ const { save, finish } = queuedNudgeSave(s);
274
+ const nudge = await pressNudge(s, save);
258
275
  expect(nudge()).not.toBe("");
259
276
 
260
277
  const undone = s.actions().undo();
@@ -267,6 +284,51 @@ it("an undo pressed while a nudge's save is queued shows the nudge undone in the
267
284
  expect(s.box()).toBe("50px");
268
285
  });
269
286
 
287
+ /** A nudge whose save is queued and then rejects, and the history requests the undo sends. */
288
+ async function failingNudge(s: Awaited<ReturnType<typeof studio>>) {
289
+ resetNudgeKeys();
290
+ let fail!: () => void;
291
+ const save = vi.fn(
292
+ () => new Promise<void>((_, reject) => (fail = () => reject(new Error("The save failed.")))),
293
+ );
294
+ const nudge = await pressNudge(s, save);
295
+ const steps: string[] = [];
296
+ const real = globalThis.fetch;
297
+ vi.stubGlobal("fetch", (url: string, init?: RequestInit) => {
298
+ if (/\/history\/(step|undo)$/.test(url)) steps.push(url);
299
+ return real(url, init);
300
+ });
301
+ return { fail: () => fail(), steps, nudge };
302
+ }
303
+
304
+ it("an undo pressed while a nudge's save fails keeps the nudge undone and steps no older edit", async () => {
305
+ const s = await studio();
306
+ await s.edit();
307
+ const n = await failingNudge(s);
308
+ expect(n.nudge()).not.toBe("");
309
+
310
+ const undone = s.actions().undo();
311
+ expect(n.nudge()).toBe("");
312
+ n.fail();
313
+ await act(() => undone);
314
+ expect(n.steps).toEqual([]);
315
+ expect(s.file()).toBe(AFTER);
316
+ expect(s.box()).toBe("50px");
317
+ expect(n.nudge()).toBe("");
318
+ });
319
+
320
+ it("an undo pressed while the only edit's save fails leaves the screen as the file, nothing put back", async () => {
321
+ const s = await studio();
322
+ s.show(BEFORE);
323
+ const n = await failingNudge(s);
324
+
325
+ const undone = s.actions().undo();
326
+ n.fail();
327
+ await act(() => undone);
328
+ expect(s.file()).toBe(BEFORE);
329
+ expect(n.nudge()).toBe("");
330
+ });
331
+
270
332
  it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
271
333
  const s = await studio();
272
334
  await s.edit();
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
7
7
  import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
8
+ import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
@@ -30,6 +31,7 @@ function mount(
30
31
  undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
31
32
  redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
32
33
  predict: () => predicted ?? null,
34
+ claims: () => 7,
33
35
  };
34
36
  const putBack = vi.fn();
35
37
  const deps = {
@@ -60,6 +62,19 @@ const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored:
60
62
  const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
61
63
 
62
64
  describe("useEditHistoryActions", () => {
65
+ it("asks for the edit claimed after the key's claim count only when an edit was saving at the key", async () => {
66
+ const { deps, actions } = mount({ ok: false, reason: "empty" });
67
+ await act(() => actions.undo());
68
+ expect(deps.editHistory.undo.mock.calls[0]![0].claimedAfter).toBeUndefined();
69
+
70
+ let land!: () => void;
71
+ trackStudioPendingEdit(new Promise<void>((resolve) => (land = resolve)));
72
+ const undone = actions.undo();
73
+ land();
74
+ await act(() => undone);
75
+ expect(deps.editHistory.undo.mock.calls[1]![0].claimedAfter).toBe(7);
76
+ });
77
+
63
78
  it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => {
64
79
  const { deps, actions } = mount(
65
80
  { ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
@@ -3,7 +3,10 @@ import { useCallback, useMemo } from "react";
3
3
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
4
4
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
5
5
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
6
- import { revertNewestStudioPendingEdit } from "../utils/studioPendingEdits";
6
+ import {
7
+ hasStudioPendingEdits,
8
+ paintBackNewestStudioPendingEdit,
9
+ } from "../utils/studioPendingEdits";
7
10
 
8
11
  interface HistoryResult {
9
12
  ok: boolean;
@@ -18,11 +21,13 @@ interface HistoryResult {
18
21
  interface HistoryFileCallbacks {
19
22
  readFile: (path: string) => Promise<string>;
20
23
  serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
24
+ claimedAfter?: number;
21
25
  }
22
26
  export interface EditHistoryHandle {
23
27
  undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
24
28
  redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
25
29
  predict?: (direction: "undo" | "redo") => { id: string; files: RestoreFiles } | null;
30
+ claims?: () => number;
26
31
  state: {
27
32
  undo: ReadonlyArray<{ createdAt: number }>;
28
33
  redo: ReadonlyArray<{ createdAt: number }>;
@@ -30,7 +35,7 @@ export interface EditHistoryHandle {
30
35
  }
31
36
 
32
37
  export interface UseEditHistoryActionsOptions {
33
- editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict">;
38
+ editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict" | "claims">;
34
39
  readOptionalProjectFile: (path: string) => Promise<string>;
35
40
  readProjectFile: (path: string) => Promise<string>;
36
41
  writeProjectFile: (path: string, content: string) => Promise<void>;
@@ -77,22 +82,28 @@ export function useEditHistoryActions({
77
82
  const predicted = editHistory.predict?.(direction) ?? null;
78
83
  const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
79
84
  const pendingEditShown =
80
- !predictedShown && direction === "undo" ? revertNewestStudioPendingEdit() : null;
81
- const putBack = predictedShown ?? pendingEditShown;
85
+ !predictedShown && direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
86
+ const putBack = predictedShown ?? pendingEditShown?.showAgain;
87
+ const claimedAfter =
88
+ direction === "undo" && hasStudioPendingEdits() ? editHistory.claims?.() : undefined;
82
89
  let result: HistoryResult = { ok: false, reason: "failed" };
83
90
  let serverSteppedShown = false;
91
+ let revertIsTheUndo = false;
84
92
  try {
85
93
  await waitForPendingDomEditSaves();
94
+ revertIsTheUndo = Boolean(pendingEditShown && !(await pendingEditShown.landed()));
95
+ if (revertIsTheUndo) return;
86
96
  result = await editHistory[direction]({
87
97
  readFile: readHistoryFile,
88
98
  serialize: serializeHistoryFiles,
99
+ claimedAfter,
89
100
  });
90
101
  const stepped = Boolean(result.ok && result.label);
91
102
  serverSteppedShown = predictedShown
92
103
  ? stepped && result.undoes === predicted?.id
93
104
  : stepped && Boolean(pendingEditShown);
94
105
  } finally {
95
- if (putBack && !serverSteppedShown) putBack();
106
+ if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
96
107
  }
97
108
  if (!result.ok && result.reason === "content-mismatch") {
98
109
  showToast(
@@ -24,6 +24,7 @@ import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditComm
24
24
  import { domEditCommitDeclined, type DomEditCommitOutcome } from "./domEditCommitRunner";
25
25
  import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
26
26
  import { studioWriteHeaders } from "../utils/studioFileVersion";
27
+ import { studioApiFetch } from "../utils/studioApiFetch";
27
28
 
28
29
  interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
29
30
  /** Route delete through SDK when session resolves the hf-id. */
@@ -156,7 +157,7 @@ export function useElementLifecycleOps({
156
157
  writeFile: writeProjectFile,
157
158
  recordEdit: editHistory.recordEdit,
158
159
  rewrite: async (originalContent) => {
159
- const removeResponse = await fetch(
160
+ const removeResponse = await studioApiFetch(
160
161
  buildProjectApiPath(
161
162
  pid,
162
163
  `/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
@@ -378,7 +378,10 @@ function makeElementSelection(): DomEditSelection {
378
378
  } as DomEditSelection;
379
379
  }
380
380
 
381
- function stubFlatTweenConversion(id: string): {
381
+ function stubFlatTweenConversion(
382
+ id: string,
383
+ easeEach?: string,
384
+ ): {
382
385
  flat: GsapAnimation;
383
386
  converted: GsapAnimation;
384
387
  } {
@@ -393,6 +396,7 @@ function stubFlatTweenConversion(id: string): {
393
396
  { percentage: 0, properties: { x: 0 } },
394
397
  { percentage: 100, properties: { x: 10 } },
395
398
  ],
399
+ ...(easeEach && { easeEach }),
396
400
  },
397
401
  });
398
402
  flatTweenResponses(flat, converted);
@@ -437,6 +441,33 @@ describe("useEnableKeyframes — flat tween transaction", () => {
437
441
  expect(convertOptions?.coalesceKey).toEqual(expect.any(String));
438
442
  });
439
443
 
444
+ it("keeps the converted tween's easeEach when it extends it to the playhead", async () => {
445
+ window.location.hash = "#/project/test-project";
446
+ usePlayerStore.setState({ currentTime: 3 });
447
+ const { flat } = stubFlatTweenConversion("flat-1", "none");
448
+ const commitMutation = vi.fn(async () => undefined);
449
+ const enable = renderEnableKeyframes({
450
+ domEditSelection: makeElementSelection(),
451
+ selectedGsapAnimations: [flat],
452
+ previewIframeRef: {
453
+ current: {
454
+ contentWindow: { gsap: { getProperty: () => 10 } },
455
+ } as unknown as HTMLIFrameElement,
456
+ },
457
+ handleGsapAddAnimation: vi.fn(),
458
+ handleGsapConvertToKeyframes: vi.fn(async () => undefined),
459
+ handleGsapRemoveKeyframe: vi.fn(),
460
+ commitMutation,
461
+ });
462
+
463
+ await act(async () => enable());
464
+
465
+ expect(commitMutation.mock.calls[0]?.[0]).toMatchObject({
466
+ type: "replace-with-keyframes",
467
+ easeEach: "none",
468
+ });
469
+ });
470
+
440
471
  it("passes the convert coalesce key to an inside-range batch edit", async () => {
441
472
  window.location.hash = "#/project/test-project";
442
473
  usePlayerStore.setState({ currentTime: 1.5 });
@@ -15,6 +15,7 @@ import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCa
15
15
  import {
16
16
  existingTweenTargetSelector,
17
17
  computeElementPercentage,
18
+ keyframeEases,
18
19
  KEYFRAME_PCT_MATCH,
19
20
  isInstantHold,
20
21
  resolveEditableTweenDuration,
@@ -266,7 +267,7 @@ async function extendKeyframedTweenToPlayhead(
266
267
  position: extended.position,
267
268
  duration: extended.duration,
268
269
  keyframes: extended.keyframes,
269
- ease: anim.ease,
270
+ ...keyframeEases(anim),
270
271
  },
271
272
  {
272
273
  label: "Add keyframe",
@@ -83,6 +83,25 @@ describe("external file change coordinator", () => {
83
83
  expect(captured.handle?.blocked).toBeNull();
84
84
  });
85
85
 
86
+ it("keeps one file-change subscription across re-renders and delivers to the latest callbacks", async () => {
87
+ const on = vi.fn((_event: string, next: HotHandler) => void (handler = next));
88
+ vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", { on, off: vi.fn() });
89
+ const { options } = await mountCoordinator();
90
+ on.mockClear();
91
+ const root = createRoot(document.createElement("div"));
92
+ roots.push(root);
93
+ function Probe({ onAccepted }: { onAccepted: () => void }) {
94
+ useExternalFileChangeCoordinator({ ...options, onAcceptedPersistedFileChange: onAccepted });
95
+ return null;
96
+ }
97
+ const latest = vi.fn();
98
+ await act(async () => root.render(<Probe onAccepted={vi.fn()} />));
99
+ await act(async () => root.render(<Probe onAccepted={latest} />));
100
+ await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
101
+ expect(on).toHaveBeenCalledOnce();
102
+ expect(latest).toHaveBeenCalledOnce();
103
+ });
104
+
86
105
  // The file tree (useFileTree) is only ever refreshed from Studio's own file
87
106
  // operations (create/delete/rename/upload) — never on an external change.
88
107
  // Without this call, an agent removing or replacing a composition updates
@@ -448,9 +448,11 @@ export function useExternalFileChangeCoordinator({
448
448
  [projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
449
449
  );
450
450
 
451
+ const processChangeRef = useRef(processChange);
452
+ processChangeRef.current = processChange;
451
453
  useEffect(() => {
452
454
  // One decoder for all three transports; the rungs only choose the channel.
453
- const handler = (delivery?: unknown) => processChange(decodeFileChange(delivery));
455
+ const handler = (delivery?: unknown) => processChangeRef.current(decodeFileChange(delivery));
454
456
  const adapter = testHotAdapter();
455
457
  if (adapter) {
456
458
  adapter.on("hf:file-change", handler);
@@ -461,7 +463,7 @@ export function useExternalFileChangeCoordinator({
461
463
  return () => import.meta.hot?.off?.("hf:file-change", handler);
462
464
  }
463
465
  return sseFileChangeChannel(handler);
464
- }, [processChange]);
466
+ }, []);
465
467
 
466
468
  const retry = useCallback(async () => {
467
469
  const current = blockedRef.current;
@@ -129,6 +129,7 @@ describe("useFileManager project ownership", () => {
129
129
 
130
130
  expect(fetchMock).toHaveBeenCalledWith(
131
131
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
132
+ { credentials: "omit" },
132
133
  );
133
134
  expect(fetchMock).toHaveBeenCalledWith(
134
135
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
@@ -136,14 +137,18 @@ describe("useFileManager project ownership", () => {
136
137
  );
137
138
  expect(fetchMock).toHaveBeenCalledWith(
138
139
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
140
+ { credentials: "omit" },
139
141
  );
140
142
  expect(fetchMock).toHaveBeenCalledWith(
141
143
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
142
144
  expect.objectContaining({ method: "PUT", body: "A_NEW" }),
143
145
  );
144
- expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html");
146
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html", {
147
+ credentials: "omit",
148
+ });
145
149
  expect(fetchMock).toHaveBeenCalledWith(
146
150
  "/api/projects/project-b%23fragment/files/index.html?optional=1",
151
+ { credentials: "omit" },
147
152
  );
148
153
 
149
154
  await act(async () => root.unmount());
@@ -8,6 +8,7 @@ import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordina
8
8
  import { useFileTree } from "./useFileTree";
9
9
  import { useEditorSave } from "./useEditorSave";
10
10
  import { useProjectFileWriter } from "./useProjectFileWriter";
11
+ import { studioApiFetch } from "../utils/studioApiFetch";
11
12
 
12
13
  // ── Types ──
13
14
 
@@ -133,7 +134,7 @@ export function useFileManager({
133
134
  setEditingFile({ path, content: null });
134
135
  return;
135
136
  }
136
- fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
137
+ studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
137
138
  .then((r) => {
138
139
  if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
139
140
  return r.json();
@@ -167,9 +168,12 @@ export function useFileManager({
167
168
  const requestId = ++revealRequestIdRef.current;
168
169
  const controller = new AbortController();
169
170
  revealAbortRef.current = controller;
170
- fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`, {
171
- signal: controller.signal,
172
- })
171
+ studioApiFetch(
172
+ `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`,
173
+ {
174
+ signal: controller.signal,
175
+ },
176
+ )
173
177
  .then((r) => r.json())
174
178
  .then((data: { content?: string; version?: string }) => {
175
179
  if (requestId !== revealRequestIdRef.current) return;
@@ -200,7 +204,7 @@ export function useFileManager({
200
204
 
201
205
  const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
202
206
  try {
203
- const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
207
+ const res = await studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
204
208
  method: "POST",
205
209
  body: formData,
206
210
  });
@@ -246,7 +250,7 @@ export function useFileManager({
246
250
  content =
247
251
  '<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
248
252
  }
249
- const res = await fetch(
253
+ const res = await studioApiFetch(
250
254
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
251
255
  {
252
256
  method: "POST",
@@ -270,7 +274,7 @@ export function useFileManager({
270
274
  async (path: string) => {
271
275
  const pid = projectIdRef.current;
272
276
  if (!pid) return;
273
- const res = await fetch(
277
+ const res = await studioApiFetch(
274
278
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path + "/.gitkeep")}`,
275
279
  {
276
280
  method: "POST",
@@ -293,7 +297,7 @@ export function useFileManager({
293
297
  async (path: string) => {
294
298
  const pid = projectIdRef.current;
295
299
  if (!pid) return;
296
- const res = await fetch(
300
+ const res = await studioApiFetch(
297
301
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
298
302
  {
299
303
  method: "DELETE",
@@ -315,7 +319,7 @@ export function useFileManager({
315
319
  async (oldPath: string, newPath: string) => {
316
320
  const pid = projectIdRef.current;
317
321
  if (!pid) return;
318
- const res = await fetch(
322
+ const res = await studioApiFetch(
319
323
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(oldPath)}`,
320
324
  {
321
325
  method: "PATCH",
@@ -342,7 +346,7 @@ export function useFileManager({
342
346
  async (path: string) => {
343
347
  const pid = projectIdRef.current;
344
348
  if (!pid) return;
345
- const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
349
+ const res = await studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
346
350
  method: "POST",
347
351
  headers: { "Content-Type": "application/json" },
348
352
  body: JSON.stringify({ path }),
@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react";
3
3
  import { FONT_EXT } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
5
5
  import { captureProjectProvenance } from "../components/feedback/projectProvenance";
6
+ import { studioApiFetch } from "../utils/studioApiFetch";
6
7
 
7
8
  interface UseFileTreeOptions {
8
9
  projectId: string | null;
@@ -38,7 +39,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
38
39
  if (!projectId) return;
39
40
  let cancelled = false;
40
41
  setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
41
- fetch(buildProjectApiPath(projectId))
42
+ studioApiFetch(buildProjectApiPath(projectId))
42
43
  // An unresolvable project answers 404 with a JSON body, so without this
43
44
  // the error path parsed cleanly and the success branch below recorded an
44
45
  // empty tree — and an empty provenance snapshot — for a project that was
@@ -84,7 +85,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
84
85
  refreshAbortRef.current = controller;
85
86
  let data: { files?: string[]; compositions?: string[] };
86
87
  try {
87
- const res = await fetch(buildProjectApiPath(pid), { signal: controller.signal });
88
+ const res = await studioApiFetch(buildProjectApiPath(pid), { signal: controller.signal });
88
89
  data = await res.json();
89
90
  } catch (err) {
90
91
  if (err instanceof DOMException && err.name === "AbortError") return;
@@ -2,6 +2,7 @@ import { useMemo, useState, useCallback, useRef, type MouseEvent } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import { liveTime, usePlayerStore } from "../player";
4
4
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
5
+ import { studioApiFetch } from "../utils/studioApiFetch";
5
6
 
6
7
  interface UseFrameCaptureParams {
7
8
  projectId: string | null;
@@ -67,7 +68,10 @@ export function useFrameCapture({
67
68
  const controller = new AbortController();
68
69
  const timeout = setTimeout(() => controller.abort(), 30000);
69
70
  try {
70
- const response = await fetch(href, { cache: "no-store", signal: controller.signal });
71
+ const response = await studioApiFetch(href, {
72
+ cache: "no-store",
73
+ signal: controller.signal,
74
+ });
71
75
  clearTimeout(timeout);
72
76
  if (!response.ok) {
73
77
  let msg = `Capture failed (${response.status})`;
@@ -1,6 +1,9 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
1
4
  import { afterEach, describe, expect, it, vi } from "vitest";
2
5
  import type { TimelineElement } from "../player";
3
- import { requestFreezeFrame } from "./useFreezeFrame";
6
+ import { requestFreezeFrame, useFreezeFrame } from "./useFreezeFrame";
4
7
 
5
8
  afterEach(() => vi.unstubAllGlobals());
6
9
 
@@ -29,14 +32,15 @@ function stubFetch(freezeResponse: { status: number; body: unknown }) {
29
32
 
30
33
  describe("requestFreezeFrame", () => {
31
34
  it("posts the clip target, file version and an authored-time playhead", async () => {
32
- const calls = stubFetch({ status: 200, body: { before: "a", after: "b", version: "v2" } });
35
+ const frozen = { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/t.png" };
36
+ const calls = stubFetch({ status: 200, body: frozen });
33
37
  const result = await requestFreezeFrame({
34
38
  projectId: "p",
35
39
  path: "scene.html",
36
40
  element,
37
41
  playhead: 7.5,
38
42
  });
39
- expect(result).toEqual({ before: "a", after: "b", version: "v2" });
43
+ expect(result).toEqual(frozen);
40
44
  const post = calls[1];
41
45
  expect(post?.url).toContain("/file-mutations/freeze-frame");
42
46
  expect(post?.body).toMatchObject({
@@ -54,3 +58,35 @@ describe("requestFreezeFrame", () => {
54
58
  ).rejects.toThrow("Move the playhead inside a video clip to freeze");
55
59
  });
56
60
  });
61
+
62
+ describe("useFreezeFrame", () => {
63
+ it("records the still it made with the edit, so Undo removes it", async () => {
64
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
65
+ stubFetch({
66
+ status: 200,
67
+ body: { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/talk.png" },
68
+ });
69
+ const recordEdit = vi.fn(async () => {});
70
+ let freeze!: ReturnType<typeof useFreezeFrame>;
71
+ function Harness() {
72
+ freeze = useFreezeFrame({
73
+ projectId: "p",
74
+ activeCompPath: "index.html",
75
+ showToast: () => {},
76
+ writeProjectFile: async () => {},
77
+ recordEdit,
78
+ reloadPreview: () => {},
79
+ });
80
+ return null;
81
+ }
82
+ const root = createRoot(document.createElement("div"));
83
+ await act(async () => root.render(createElement(Harness)));
84
+ await act(() => freeze({ ...element, sourceFile: "index.html" }, 7.5));
85
+ expect(recordEdit).toHaveBeenCalledWith({
86
+ label: "Freeze frame",
87
+ files: { "index.html": { before: "a", after: "b" } },
88
+ created: ["assets/freeze/talk.png"],
89
+ });
90
+ act(() => root.unmount());
91
+ });
92
+ });
@@ -6,6 +6,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
6
6
  import { markStudioWriteToken } from "../utils/studioFileVersion";
7
7
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
8
8
  import type { RecordEditInput } from "../utils/studioFileHistory";
9
+ import { studioApiFetch } from "../utils/studioApiFetch";
9
10
 
10
11
  type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
11
12
 
@@ -13,6 +14,7 @@ interface FreezeFrameResponse {
13
14
  before: string;
14
15
  after: string;
15
16
  version: string;
17
+ stillPath: string;
16
18
  }
17
19
 
18
20
  function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
@@ -21,7 +23,8 @@ function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
21
23
  return (
22
24
  typeof body.before === "string" &&
23
25
  typeof body.after === "string" &&
24
- typeof body.version === "string"
26
+ typeof body.version === "string" &&
27
+ typeof body.stillPath === "string"
25
28
  );
26
29
  }
27
30
 
@@ -31,7 +34,7 @@ function errorOf(value: unknown): string | null {
31
34
  }
32
35
 
33
36
  async function readVersion(projectId: string, path: string): Promise<string> {
34
- const response = await fetch(
37
+ const response = await studioApiFetch(
35
38
  buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
36
39
  );
37
40
  const body: unknown = await response.json().catch(() => null);
@@ -53,7 +56,7 @@ export async function requestFreezeFrame(input: {
53
56
  const expectedVersion = await readVersion(input.projectId, input.path);
54
57
  const transactionToken = `freeze:${crypto.randomUUID()}`;
55
58
  markStudioWriteToken(transactionToken);
56
- const response = await fetch(
59
+ const response = await studioApiFetch(
57
60
  buildProjectApiPath(input.projectId, "/file-mutations/freeze-frame"),
58
61
  {
59
62
  method: "POST",
@@ -108,6 +111,7 @@ export function useFreezeFrame(options: UseFreezeFrameOptions) {
108
111
  await opts.recordEdit({
109
112
  label: "Freeze frame",
110
113
  files: { [path]: { before: result.before, after: result.after } },
114
+ created: [result.stillPath],
111
115
  });
112
116
  opts.observeProjectFileVersion?.(path, result.version);
113
117
  });