@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
@@ -36,6 +36,7 @@ import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFi
36
36
  import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
37
37
  import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
38
38
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
39
+ import { isStudioManualEditGestureLiveIn } from "../../components/editor/manualEditsDom";
39
40
  import { createPreviewMessageHandler } from "./previewMessageRouter";
40
41
  import { timelineElementsChanged } from "./timelinePlayerSync";
41
42
  import { safeContentDocument } from "./timelineSyncHydration";
@@ -486,7 +487,10 @@ export function useTimelinePlayer({
486
487
  // A newer edit, or anything replacing the live preview (a reload, a composition switch), wins.
487
488
  const isCurrent = () => gen === refreshGenRef.current && slot === previewGeneration();
488
489
  const swap = sceneSwapFor(iframe);
489
- if (!swap || isRefreshingRef.current) return reloadWholeFilm(url.toString());
490
+ const swapWouldReplaceGestureNode =
491
+ !!iframe.contentDocument && isStudioManualEditGestureLiveIn(iframe.contentDocument);
492
+ if (!swap || isRefreshingRef.current || swapWouldReplaceGestureNode)
493
+ return reloadWholeFilm(url.toString());
490
494
  swap(url.toString(), isCurrent, cancel.signal).catch((error: unknown) => {
491
495
  if (!isCurrent()) return;
492
496
  logReload("scene-swap-refused", { reason: String(error) });
@@ -219,6 +219,22 @@ describe("ThumbnailScheduler", () => {
219
219
  expect(scheduler.getDiagnostics()).toMatchObject({ queued: 0, active: 0, leases: 0 });
220
220
  });
221
221
 
222
+ it("frees an aborted job's slot at once, even when its loader ignores the abort", async () => {
223
+ const scheduler = new ThumbnailScheduler(
224
+ resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }),
225
+ );
226
+ const stuck = deferred<ThumbnailLoadedResult>();
227
+ const lease = scheduler.acquire(
228
+ request("stuck", () => stuck.promise, "visible", { kind: "video" }),
229
+ vi.fn(),
230
+ );
231
+ lease.release();
232
+ const nextLoad = vi.fn(async () => result("next"));
233
+ scheduler.acquire(request("next", nextLoad, "visible", { kind: "video" }), vi.fn());
234
+ await flush();
235
+ expect(nextLoad).toHaveBeenCalledTimes(1);
236
+ });
237
+
222
238
  it("disposes a late result exactly once after its final lease releases", async () => {
223
239
  const scheduler = new ThumbnailScheduler();
224
240
  const pending = deferred<ThumbnailLoadedResult>();
@@ -310,6 +310,15 @@ export class ThumbnailScheduler {
310
310
  entry.controller = controller;
311
311
  this.activeByBucket[bucket]++;
312
312
  this.activeByKind[entry.request.kind]++;
313
+ let slotFree = false;
314
+ const freeSlot = () => {
315
+ if (slotFree) return;
316
+ slotFree = true;
317
+ this.activeByBucket[bucket]--;
318
+ this.activeByKind[entry.request.kind]--;
319
+ this.pump();
320
+ };
321
+ controller.signal.addEventListener("abort", freeSlot, { once: true });
313
322
  this.notify(entry);
314
323
 
315
324
  const pending = this.loadWithTimeout(entry, controller);
@@ -331,8 +340,7 @@ export class ThumbnailScheduler {
331
340
  })
332
341
  .finally(() => {
333
342
  if (entry.controller === controller) entry.controller = null;
334
- this.activeByBucket[bucket]--;
335
- this.activeByKind[entry.request.kind]--;
343
+ freeSlot();
336
344
  this.pump();
337
345
  });
338
346
  }
@@ -1,3 +1,4 @@
1
+ import { studioApiFetch } from "../utils/studioApiFetch";
1
2
  type SwapWindow = Window & {
2
3
  __hfSwapScenes?: (html: string, signal?: AbortSignal) => Promise<void>;
3
4
  };
@@ -72,7 +73,7 @@ export function sceneSwapFor(
72
73
  );
73
74
  cancel?.addEventListener("abort", () => deadline.abort(cancel.reason), { once: true });
74
75
  const swapping = (async () => {
75
- const response = await fetch(url, { signal: deadline.signal });
76
+ const response = await studioApiFetch(url, { signal: deadline.signal });
76
77
  if (!response.ok) throw new Error(`preview request failed with ${response.status}`);
77
78
  const html = await response.text();
78
79
  if (!isCurrent()) throw new Error("superseded by a newer reload");
@@ -9,6 +9,7 @@ import { browserTelemetryAllowed } from "./policy";
9
9
  import { getBrowserSystemMeta } from "./system";
10
10
  import { canaryEventProperties } from "./canary";
11
11
  import { recordBreadcrumb } from "./breadcrumbs";
12
+ import { studioApiFetch } from "../utils/studioApiFetch";
12
13
 
13
14
  // Write-only PostHog project key, safe to embed in client code.
14
15
  const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -83,7 +84,7 @@ function send(url: string, payload: string): void {
83
84
  // Prefer fetch with keepalive (survives page navigation). sendBeacon is a
84
85
  // fallback for older runtimes where fetch isn't available.
85
86
  try {
86
- void fetch(url, {
87
+ void studioApiFetch(url, {
87
88
  method: "POST",
88
89
  headers: { "Content-Type": "application/json" },
89
90
  body: payload,
@@ -12,6 +12,7 @@ import { saveProjectFilesWithHistory } from "./studioFileHistory";
12
12
  import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
13
13
  import { extendRootDurationInSource } from "./rootDuration";
14
14
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
15
+ import { studioApiFetch } from "./studioApiFetch";
15
16
 
16
17
  function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
17
18
  try {
@@ -65,7 +66,7 @@ async function installRegistryItem({
65
66
  block: RegistryItem;
66
67
  compositionFile: string;
67
68
  } | null> {
68
- const response = await fetch(buildProjectApiPath(projectId, `/registry/install`), {
69
+ const response = await studioApiFetch(buildProjectApiPath(projectId, `/registry/install`), {
69
70
  method: "POST",
70
71
  headers: { "Content-Type": "application/json" },
71
72
  body: JSON.stringify({ blockName }),
@@ -0,0 +1,9 @@
1
+ // @vitest-environment happy-dom
2
+ import { expect, it } from "vitest";
3
+ import { elementTargets } from "./elementGsap";
4
+
5
+ it("keeps only the targets GSAP styles: not a plain object, not an XML-namespace element", () => {
6
+ const box = document.createElement("div");
7
+ const item = document.createElementNS("urn:x", "item");
8
+ expect(elementTargets({ targets: () => [{}, item, box, null] })).toEqual([box]);
9
+ });
@@ -43,3 +43,10 @@ export function readElementGsapNumber(element: HTMLElement, prop: string): numbe
43
43
  const value = Number(gsapOf(element)?.getProperty?.(element, prop));
44
44
  return Number.isFinite(value) ? value : null;
45
45
  }
46
+
47
+ /** The targets CSSPlugin styles: not plain objects (the runtime's filler) or XML-namespace elements. */
48
+ export function elementTargets(tween: { targets?: () => unknown[] }): Element[] {
49
+ return (tween.targets?.() ?? []).filter((t): t is Element =>
50
+ Boolean((t as HTMLElement | null)?.style && (t as Node).nodeType),
51
+ );
52
+ }
@@ -481,6 +481,45 @@ describe("applySoftReload authored-style restore", () => {
481
481
  return el.style.getPropertyValue("opacity");
482
482
  }
483
483
 
484
+ it("flushes only elements when a tween targets a plain object (the runtime's duration filler)", () => {
485
+ const el = document.createElement("div");
486
+ // GSAP's clearProps writes target.style.cssText, which throws on a plain object.
487
+ const set = vi.fn((targets: Array<{ style: CSSStyleDeclaration }>) => {
488
+ for (const t of targets) t.style.cssText = "";
489
+ });
490
+ const { iframe } = buildIframeWithTarget(el, {
491
+ gsap: { timeline: vi.fn(), set },
492
+ __timelines: {
493
+ root: {
494
+ kill: vi.fn(),
495
+ getChildren: () => [{ targets: () => [el] }, { targets: () => [{}] }],
496
+ },
497
+ },
498
+ });
499
+
500
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
501
+ expect(set).toHaveBeenCalledWith([el], { clearProps: "all" });
502
+ });
503
+
504
+ it("falls back to a full reload, and says why, when the flush throws", () => {
505
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
506
+ const { iframe } = buildIframeWithTarget(document.createElement("div"), {
507
+ gsap: {
508
+ timeline: vi.fn(),
509
+ set: vi.fn(() => {
510
+ throw new Error("flush failed");
511
+ }),
512
+ },
513
+ });
514
+
515
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
516
+ expect(error).toHaveBeenCalledWith(
517
+ expect.stringContaining("soft reload threw"),
518
+ expect.any(Error),
519
+ );
520
+ error.mockRestore();
521
+ });
522
+
484
523
  it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
485
524
  const el = document.createElement("img");
486
525
  el.setAttribute("data-hf-id", "hf-1");
@@ -152,7 +152,8 @@ function runSuppressed(win: IframeWindow, reload: () => void): boolean {
152
152
  if (win.__hfSuppressSceneMutations) win.__hfSuppressSceneMutations(reload);
153
153
  else reload();
154
154
  return true;
155
- } catch {
155
+ } catch (error) {
156
+ console.error("[Studio] GSAP soft reload threw; falling back to a full reload", error);
156
157
  return false;
157
158
  }
158
159
  }
@@ -209,7 +210,6 @@ export interface SoftReloadOptions {
209
210
  currentTimeOverride?: number;
210
211
  /** After-write file HTML — the primary source for the authored opacity and transform restore. */
211
212
  authoredHtml?: string;
212
- reparse?: Element[];
213
213
  /** Other composition files a reset element is written in, by path; null when one could not be read. */
214
214
  nestedFiles?: Map<string, string> | null;
215
215
  }
@@ -274,7 +274,7 @@ export function applySoftReload(
274
274
  scriptText: string,
275
275
  options: SoftReloadOptions = {},
276
276
  ): SoftReloadResult {
277
- const { onAsyncFailure, currentTimeOverride, authoredHtml, reparse = [] } = options;
277
+ const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
278
278
  if (!iframe || !scriptText) return "cannot-soft-reload";
279
279
 
280
280
  const win = iframe.contentWindow as IframeWindow | null;
@@ -347,7 +347,7 @@ export function applySoftReload(
347
347
  // fallow-ignore-next-line complexity
348
348
  const doReload = () => {
349
349
  const timelines = win.__timelines;
350
- const targets = collectResetTargets(win, doc, targetKeys, reparse);
350
+ const targets = collectResetTargets(win, doc, targetKeys);
351
351
 
352
352
  // Kill ONLY the target composition's timeline(s) — leaving every other
353
353
  // composition's timeline (and its children on the global timeline) intact.
@@ -370,16 +370,11 @@ export function applySoftReload(
370
370
  // HTML `style=""` attribute. Save → clear → restore → what GSAP wrote, from the file.
371
371
  const allTargets = [...targets.keys()];
372
372
  if (allTargets.length > 0 && win.gsap?.set) {
373
- const saved: Array<[HTMLElement, string]> = [];
374
- for (const el of allTargets) {
375
- // Iframe-realm node: instanceof HTMLElement fails across realms, and
376
- // gsap targets() only yields elements here — style access is duck-typed.
377
- const styled = el as HTMLElement;
378
- if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
379
- }
380
- try {
381
- win.gsap.set(allTargets, { clearProps: "all" });
382
- } catch {}
373
+ // Iframe-realm nodes: instanceof HTMLElement fails across realms, so style access is duck-typed.
374
+ const saved = allTargets.map(
375
+ (el) => [el as HTMLElement, (el as HTMLElement).style.cssText] as const,
376
+ );
377
+ win.gsap.set(allTargets, { clearProps: "all" });
383
378
  for (const [el, css] of saved) {
384
379
  const s = el.style;
385
380
  s.cssText = css;
@@ -16,11 +16,9 @@ const CROP = "inset(0px 40px 0px 0px)";
16
16
  vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
17
17
  let browser: Browser;
18
18
  let undoBundle: string;
19
+ let softReloadBundle: string;
19
20
 
20
- beforeAll(async () => {
21
- const executablePath = findSystemChrome();
22
- if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
23
- browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
21
+ async function bundle(file: string, name: string): Promise<string> {
24
22
  const out = await build({
25
23
  configFile: false,
26
24
  logLevel: "silent",
@@ -30,44 +28,46 @@ beforeAll(async () => {
30
28
  build: {
31
29
  write: false,
32
30
  minify: false,
33
- lib: {
34
- entry: fileURLToPath(new URL("./gsapUndoRestore.ts", import.meta.url)),
35
- formats: ["iife"],
36
- name: "hfUndo",
37
- },
31
+ lib: { entry: fileURLToPath(new URL(file, import.meta.url)), formats: ["iife"], name },
38
32
  },
39
33
  });
40
- undoBundle = (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0]
41
- .code;
34
+ return (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0].code;
35
+ }
36
+
37
+ beforeAll(async () => {
38
+ const executablePath = findSystemChrome();
39
+ if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
40
+ browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
41
+ undoBundle = await bundle("./gsapUndoRestore.ts", "hfUndo");
42
+ softReloadBundle = await bundle("./gsapSoftReload.ts", "hfSoftReload");
42
43
  });
43
44
 
44
45
  afterAll(() => browser?.close());
45
46
 
46
- it.each([
47
- { gsap: "hold", placement: "px", rotation: 0 },
48
- { gsap: "tween", placement: "px", rotation: 0 },
49
- { gsap: "tween", placement: "xpercent", rotation: 30 },
50
- ])("an undone crop leaves a $gsap $placement r$rotation layer where it was", async (spec) => {
47
+ type Spec = { gsap: string; placement: string; rotation: number; time: number };
48
+
49
+ /** The bench's root crop fixture, shown at `spec.time` with the module under test loaded. */
50
+ async function openCase(spec: Spec, moduleBundle: string) {
51
51
  const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
52
52
  writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
53
53
  const restored = readFileSync(join(dir, "index.html"), "utf8");
54
- const previous = restored.replace(
55
- `<div id="target"`,
56
- `<div id="target" style="clip-path: ${CROP}"`,
57
- );
58
54
  const page = await browser.newPage();
59
55
  await page.setViewport({ width: 1920, height: 1080 });
60
56
  await page.setRequestInterception(true);
57
+ // Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
61
58
  page.on("request", (request) =>
62
59
  request.url().endsWith("/gsap.min.js")
63
60
  ? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
64
- : request.continue(),
61
+ : request.abort("blockedbyclient"),
65
62
  );
66
63
  await page.setContent(restored, { waitUntil: "load" });
67
64
  await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
68
65
  await page.waitForFunction(() => "__player" in window);
69
- await page.evaluate(undoBundle);
70
-
66
+ await page.evaluate(moduleBundle);
67
+ await page.evaluate(
68
+ (time) => (window as unknown as { __player: { seek(t: number): void } }).__player.seek(time),
69
+ spec.time,
70
+ );
71
71
  const box = () =>
72
72
  page.evaluate(() => {
73
73
  const { left, top, width, height } = document
@@ -75,22 +75,75 @@ it.each([
75
75
  .getBoundingClientRect();
76
76
  return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
77
77
  });
78
- await page.evaluate(() =>
79
- (window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
80
- );
81
- const before = await box();
82
- const outcome = await page.evaluate(
83
- (files, crop) => {
84
- document.getElementById("target")!.style.setProperty("clip-path", crop);
85
- const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
86
- const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
87
- return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
88
- },
89
- { "index.html": { previous, restored } },
90
- CROP,
91
- );
78
+ return { page, restored, box };
79
+ }
92
80
 
93
- expect(outcome).toBe("soft");
94
- expect(await box()).toEqual(before);
95
- await page.close();
96
- });
81
+ // The first case opens the cold browser's first page, which overran 5 s on a loaded CI runner.
82
+ it.each([
83
+ { gsap: "hold", placement: "px", rotation: 0, time: 1 },
84
+ { gsap: "tween", placement: "px", rotation: 0, time: 1 },
85
+ { gsap: "tween", placement: "xpercent", rotation: 30, time: 1 },
86
+ { gsap: "keys", placement: "px", rotation: 0, time: 1 },
87
+ { gsap: "keys", placement: "px", rotation: 30, time: 2 },
88
+ { gsap: "scale", placement: "px", rotation: 0, time: 1 },
89
+ { gsap: "spin", placement: "px", rotation: 30, time: 1 },
90
+ { gsap: "fromto", placement: "px", rotation: 0, time: 1 },
91
+ ])(
92
+ "an undone crop leaves a $gsap $placement r$rotation layer at t$time where it was",
93
+ async (spec) => {
94
+ const { page, restored, box } = await openCase(spec, undoBundle);
95
+ const previous = restored.replace(
96
+ `<div id="target"`,
97
+ `<div id="target" style="clip-path: ${CROP}"`,
98
+ );
99
+ const before = await box();
100
+ const outcome = await page.evaluate(
101
+ (files, crop, time) => {
102
+ document.getElementById("target")!.style.setProperty("clip-path", crop);
103
+ const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
104
+ const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
105
+ return undo.applyUndoRestoreToPreview(iframe, "index.html", files, time, () => {});
106
+ },
107
+ { "index.html": { previous, restored } },
108
+ CROP,
109
+ spec.time,
110
+ );
111
+
112
+ expect(outcome).toBe("soft");
113
+ expect(await box()).toEqual(before);
114
+ await page.close();
115
+ },
116
+ 30_000,
117
+ );
118
+
119
+ // A drop saves the file and soft-reloads it; the re-run must place an animated layer where it was.
120
+ it.each([
121
+ { gsap: "tween", placement: "px", rotation: 0, time: 1 },
122
+ { gsap: "keys", placement: "px", rotation: 30, time: 2 },
123
+ { gsap: "spin", placement: "px", rotation: 0, time: 1 },
124
+ ])(
125
+ "a soft reload keeps a $gsap $placement r$rotation layer at t$time where it was",
126
+ async (spec) => {
127
+ const { page, restored, box } = await openCase(spec, softReloadBundle);
128
+ const before = await box();
129
+ const outcome = await page.evaluate(
130
+ (html, time) => {
131
+ const soft = (window as unknown as { hfSoftReload: typeof import("./gsapSoftReload") })
132
+ .hfSoftReload;
133
+ const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
134
+ const script = soft.extractGsapScriptText(html)!;
135
+ return soft.applySoftReload(iframe, script, {
136
+ currentTimeOverride: time,
137
+ authoredHtml: html,
138
+ });
139
+ },
140
+ restored,
141
+ spec.time,
142
+ );
143
+
144
+ expect(outcome).toBe("applied");
145
+ expect(await box()).toEqual(before);
146
+ await page.close();
147
+ },
148
+ 30_000,
149
+ );
@@ -398,20 +398,17 @@ export function applyUndoRestoreToPreview(
398
398
  const active = files[activeDocPath];
399
399
  const restoredScript = active ? extractGsapScriptText(active.restored) : null;
400
400
  const previousScript = active ? extractGsapScriptText(active.previous) : null;
401
- const reparse = plan.scripted
402
- ? plan.targets.map(({ live }) => live).filter((el) => "_gsap" in el)
403
- : [];
404
- if (reparse.length && !restoredScript) {
401
+ const reparse = plan.scripted && plan.targets.some(({ live }) => "_gsap" in live);
402
+ if (reparse && !restoredScript) {
405
403
  reloadPreview();
406
404
  return "full";
407
405
  }
408
- if (restoredScript && (restoredScript !== previousScript || reparse.length)) {
406
+ if (restoredScript && (restoredScript !== previousScript || reparse)) {
409
407
  syncStaleEditMarks(doc, active);
410
408
  const result = applySoftReload(iframe, restoredScript, {
411
409
  onAsyncFailure: reloadPreview,
412
410
  currentTimeOverride: currentTime,
413
411
  authoredHtml: active.restored,
414
- reparse,
415
412
  nestedFiles,
416
413
  });
417
414
  if (result === "cannot-soft-reload") {
@@ -1,5 +1,6 @@
1
1
  import { applyPreviewVariablesToUrl } from "../hooks/previewVariablesStore";
2
2
  import { buildProjectApiPath, parseProjectIdFromHash } from "./projectRouting";
3
+ import { studioApiFetch } from "./studioApiFetch";
3
4
 
4
5
  let prefetched: string | null = null;
5
6
 
@@ -10,5 +11,5 @@ export function prefetchPreviewForHash(hash: string): void {
10
11
  prefetched = projectId;
11
12
  const url = new URL(buildProjectApiPath(projectId, "/preview"), window.location.origin);
12
13
  applyPreviewVariablesToUrl(url);
13
- fetch(url).catch(() => undefined);
14
+ studioApiFetch(url).catch(() => undefined);
14
15
  }
@@ -6,6 +6,7 @@ import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
6
6
  import { buildProjectApiPath } from "./projectRouting";
7
7
  import { markStudioWriteToken } from "./studioFileVersion";
8
8
  import { resolveElementTrack } from "./studioHelpers";
9
+ import { studioApiFetch } from "./studioApiFetch";
9
10
 
10
11
  type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
11
12
 
@@ -101,7 +102,7 @@ export function buildAtomicCutIntents(
101
102
  }
102
103
 
103
104
  async function readFileVersion(projectId: string, path: string): Promise<string> {
104
- const response = await fetch(
105
+ const response = await studioApiFetch(
105
106
  buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
106
107
  );
107
108
  if (!response.ok) throw new Error(`Failed to read ${path} before cut (${response.status})`);
@@ -124,14 +125,17 @@ async function requestAtomicCut(
124
125
  }
125
126
  const transactionToken = `cut:${crypto.randomUUID()}`;
126
127
  markStudioWriteToken(transactionToken);
127
- const response = await fetch(buildProjectApiPath(projectId, "/file-mutations/split-batch"), {
128
- method: "POST",
129
- headers: {
130
- "Content-Type": "application/json",
131
- "X-Hyperframes-Write-Token": transactionToken,
128
+ const response = await studioApiFetch(
129
+ buildProjectApiPath(projectId, "/file-mutations/split-batch"),
130
+ {
131
+ method: "POST",
132
+ headers: {
133
+ "Content-Type": "application/json",
134
+ "X-Hyperframes-Write-Token": transactionToken,
135
+ },
136
+ body: JSON.stringify({ files, transactionToken }),
132
137
  },
133
- body: JSON.stringify({ files, transactionToken }),
134
- });
138
+ );
135
139
  const body = (await response.json().catch(() => null)) as
136
140
  | (Partial<CutBatchResponse> & { error?: string; outcome?: string })
137
141
  | null;
@@ -1,8 +1,9 @@
1
1
  // Which elements a GSAP soft reload resets, what their tweens wrote, and which file writes each.
2
2
  import { parseSavedSource } from "./authoredSource";
3
+ import { elementTargets } from "./elementGsap";
3
4
 
4
5
  type TweenLike = {
5
- targets?: () => Element[];
6
+ targets?: () => unknown[];
6
7
  vars?: Record<string, unknown>;
7
8
  getChildren?: (deep: boolean) => TweenLike[];
8
9
  };
@@ -36,7 +37,7 @@ export function authoringFile(el: Element): string | null {
36
37
 
37
38
  function addTweenTargets(tween: TweenLike, targets: Map<Element, Set<string>>): void {
38
39
  const props = tweenedProps(tween.vars);
39
- for (const el of tween.targets?.() ?? []) {
40
+ for (const el of elementTargets(tween)) {
40
41
  const seen = targets.get(el) ?? new Set<string>();
41
42
  for (const prop of props) seen.add(prop);
42
43
  targets.set(el, seen);
@@ -48,10 +49,8 @@ export function collectResetTargets(
48
49
  win: { __timelines?: Record<string, unknown> },
49
50
  doc: Document,
50
51
  targetKeys: string[],
51
- reparse: Element[] = [],
52
52
  ): Map<Element, Set<string>> {
53
- // Elements the caller needs GSAP to parse again, with nothing tweened yet.
54
- const targets = new Map<Element, Set<string>>(reparse.map((el) => [el, new Set<string>()]));
53
+ const targets = new Map<Element, Set<string>>();
55
54
  const timelines = (win.__timelines ?? {}) as Record<string, TweenLike | undefined>;
56
55
  const others = new Set<unknown>(
57
56
  Object.entries(timelines).flatMap(([key, tl]) => (targetKeys.includes(key) ? [] : [tl])),
@@ -70,7 +69,7 @@ function tweenTargetsIn(timelines: Set<unknown>): Set<Element> {
70
69
  for (const tl of timelines) {
71
70
  try {
72
71
  for (const tween of (tl as TweenLike | undefined)?.getChildren?.(true) ?? []) {
73
- for (const el of tween.targets?.() ?? []) els.add(el);
72
+ for (const el of elementTargets(tween)) els.add(el);
74
73
  }
75
74
  } catch {}
76
75
  }
@@ -0,0 +1,75 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ async function loadWithEnv(env: Record<string, string>) {
4
+ vi.resetModules();
5
+ for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
6
+ return import("./studioApiFetch");
7
+ }
8
+
9
+ describe("studioApiFetch", () => {
10
+ afterEach(() => {
11
+ vi.unstubAllGlobals();
12
+ vi.unstubAllEnvs();
13
+ vi.resetModules();
14
+ });
15
+
16
+ it("sends Studio's requests without credentials, keeping the caller's options", async () => {
17
+ const fetchMock = vi.fn(async () => new Response("ok"));
18
+ vi.stubGlobal("fetch", fetchMock);
19
+ const { studioApiFetch } = await loadWithEnv({});
20
+ const signal = new AbortController().signal;
21
+
22
+ await studioApiFetch("/api/projects/demo/history/step", { method: "POST", signal });
23
+
24
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/history/step", {
25
+ method: "POST",
26
+ signal,
27
+ credentials: "omit",
28
+ });
29
+ });
30
+
31
+ it.each([
32
+ ["the CLI or Studio dev server", "http://localhost:5190/"],
33
+ ["Desktop's own server", "http://127.0.0.1:43117/"],
34
+ ["IPv6 loopback", "http://[::1]:8080/"],
35
+ ])("leaves cookies out when Studio is served from %s", async (_, page) => {
36
+ const fetchMock = vi.fn(async () => new Response("ok"));
37
+ vi.stubGlobal("fetch", fetchMock);
38
+ vi.stubGlobal("location", new URL(page));
39
+ const { studioApiFetch } = await loadWithEnv({});
40
+
41
+ await studioApiFetch("/api/projects/demo/files/index.html");
42
+
43
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
44
+ credentials: "omit",
45
+ });
46
+ });
47
+
48
+ it("keeps same-origin cookies off loopback, where a proxy or tunnel may authenticate with them", async () => {
49
+ const fetchMock = vi.fn(async () => new Response("ok"));
50
+ vi.stubGlobal("fetch", fetchMock);
51
+ vi.stubGlobal("location", new URL("https://studio.example.com/editor"));
52
+ const { studioApiFetch } = await loadWithEnv({});
53
+
54
+ await studioApiFetch("/api/projects/demo/files/index.html");
55
+
56
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
57
+ credentials: "same-origin",
58
+ });
59
+ });
60
+
61
+ it("sends same-origin credentials on loopback for a host that opted out", async () => {
62
+ vi.stubGlobal("location", new URL("http://localhost:5190/"));
63
+ const fetchMock = vi.fn(async () => new Response("ok"));
64
+ vi.stubGlobal("fetch", fetchMock);
65
+ const { studioApiFetch } = await loadWithEnv({
66
+ VITE_STUDIO_API_SAME_ORIGIN_CREDENTIALS: "true",
67
+ });
68
+
69
+ await studioApiFetch("/api/projects/demo/files/index.html");
70
+
71
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
72
+ credentials: "same-origin",
73
+ });
74
+ });
75
+ });
@@ -0,0 +1,13 @@
1
+ import { STUDIO_API_SAME_ORIGIN_CREDENTIALS } from "../components/editor/manualEditingAvailability";
2
+
3
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
4
+
5
+ function studioApiCredentials(): RequestCredentials {
6
+ if (STUDIO_API_SAME_ORIGIN_CREDENTIALS) return "same-origin";
7
+ const host = globalThis.location?.hostname;
8
+ return !host || LOOPBACK_HOSTS.has(host) ? "omit" : "same-origin";
9
+ }
10
+
11
+ export function studioApiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
12
+ return globalThis.fetch(input, { ...init, credentials: studioApiCredentials() });
13
+ }