@hyperframes/studio 0.8.193 → 0.8.195

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.
package/dist/index.html CHANGED
@@ -14,7 +14,7 @@
14
14
  document.documentElement.dataset.theme = "paper";
15
15
  }
16
16
  </script>
17
- <script type="module" crossorigin src="/assets/index-Ca-mgTnS.js"></script>
17
+ <script type="module" crossorigin src="/assets/index-Dkt4c1MV.js"></script>
18
18
  <link rel="stylesheet" crossorigin href="/assets/index-CYbvOVp-.css">
19
19
  </head>
20
20
  <body>
package/dist/index.js CHANGED
@@ -17608,7 +17608,7 @@ async function writeProjectFilesWithHistoryInQueue({
17608
17608
 
17609
17609
  // src/utils/gsapSoftReload.ts
17610
17610
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR as COLOR_GRADING_SOURCE_HIDDEN_ATTR2 } from "@hyperframes/core/color-grading";
17611
- import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
17611
+ import { GSAP_BASE_URL_META, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
17612
17612
 
17613
17613
  // src/utils/authoredSource.ts
17614
17614
  import { ensureHfIds, isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
@@ -17870,6 +17870,12 @@ function fileDocs(ownFile, written, nestedFiles) {
17870
17870
 
17871
17871
  // src/utils/gsapSoftReload.ts
17872
17872
  var pendingReadyRetry = null;
17873
+ function previewMotionPathUrl(doc, version) {
17874
+ const baseUrl = doc.head.querySelector(
17875
+ `meta[name="${GSAP_BASE_URL_META}"]`
17876
+ )?.content;
17877
+ return motionPathPluginUrl(version, baseUrl);
17878
+ }
17873
17879
  function ensureMotionPathPluginLoaded(iframe) {
17874
17880
  if (pendingReadyRetry) window.removeEventListener("message", pendingReadyRetry);
17875
17881
  pendingReadyRetry = null;
@@ -17900,7 +17906,7 @@ function ensureMotionPathPluginLoaded(iframe) {
17900
17906
  try {
17901
17907
  win.__hfMotionPathPluginLoading = true;
17902
17908
  const pluginScript = doc.createElement("script");
17903
- pluginScript.src = motionPathPluginUrl(win.gsap?.version);
17909
+ pluginScript.src = previewMotionPathUrl(doc, win.gsap?.version);
17904
17910
  const finalize = () => {
17905
17911
  win.__hfMotionPathPluginLoading = false;
17906
17912
  try {
@@ -18086,7 +18092,7 @@ function applySoftReload(iframe, scriptText, options = {}) {
18086
18092
  }
18087
18093
  win.__hfMotionPathPluginLoading = true;
18088
18094
  const pluginScript = doc.createElement("script");
18089
- pluginScript.src = motionPathPluginUrl(win.gsap?.version);
18095
+ pluginScript.src = previewMotionPathUrl(doc, win.gsap?.version);
18090
18096
  pluginScript.onload = () => {
18091
18097
  win.__hfMotionPathPluginLoading = false;
18092
18098
  executeScript();
@@ -43350,6 +43356,7 @@ import { useCallback as useCallback61 } from "react";
43350
43356
 
43351
43357
  // src/components/editor/useLayerRevealOverride.ts
43352
43358
  import { useCallback as useCallback60, useEffect as useEffect56, useRef as useRef73 } from "react";
43359
+ import { revealClip } from "@hyperframes/core/runtime/clip-reveal";
43353
43360
  var LAYER_REVEAL_LIFT_Z = "2147483000";
43354
43361
  var LAYER_REVEAL_PENDING_COMMIT_ATTR = "data-hf-studio-reveal-pending-commit";
43355
43362
  var revealCommitSequence = 0;
@@ -43430,9 +43437,10 @@ function rollbackLayerRevealCommit(element, ownership) {
43430
43437
  }
43431
43438
  }
43432
43439
  function restoreIfOurs(el, property, applied, prior) {
43433
- if (applied == null) return;
43434
- if (el.style.getPropertyValue(property) !== applied) return;
43440
+ if (applied == null) return false;
43441
+ if (el.style.getPropertyValue(property) !== applied) return false;
43435
43442
  restoreInline(el, property, prior);
43443
+ return true;
43436
43444
  }
43437
43445
  function readHideSignals(el, win) {
43438
43446
  const computed = win.getComputedStyle(el);
@@ -43450,20 +43458,21 @@ function revealNode(el, win, needs) {
43450
43458
  opacity: el.style.opacity
43451
43459
  };
43452
43460
  const applied = {};
43461
+ let putMarkBack;
43453
43462
  if (needs.display) {
43454
43463
  el.style.removeProperty("display");
43455
43464
  if (win.getComputedStyle(el).display === "none") el.style.display = "block";
43456
43465
  applied.display = el.style.display;
43457
43466
  }
43458
43467
  if (needs.visibility) {
43459
- el.style.visibility = "visible";
43468
+ putMarkBack = revealClip(el);
43460
43469
  applied.visibility = "visible";
43461
43470
  }
43462
43471
  if (needs.opacity) {
43463
43472
  el.style.opacity = "1";
43464
43473
  applied.opacity = "1";
43465
43474
  }
43466
- return { element: el, priors, applied };
43475
+ return { element: el, priors, applied, putMarkBack };
43467
43476
  }
43468
43477
  function revealHiddenChain(element) {
43469
43478
  const doc = element.ownerDocument;
@@ -43532,10 +43541,11 @@ function useLayerRevealOverride({
43532
43541
  const state = stateRef.current;
43533
43542
  stateRef.current = null;
43534
43543
  if (!state) return;
43535
- for (const { element, priors, applied } of state.nodes) {
43544
+ for (const { element, priors, applied, putMarkBack } of state.nodes) {
43536
43545
  if (!element.isConnected) continue;
43537
43546
  restoreIfOurs(element, "display", applied.display, priors.display);
43538
- restoreIfOurs(element, "visibility", applied.visibility, priors.visibility);
43547
+ if (restoreIfOurs(element, "visibility", applied.visibility, priors.visibility))
43548
+ putMarkBack?.();
43539
43549
  restoreIfOurs(element, "opacity", applied.opacity, priors.opacity);
43540
43550
  }
43541
43551
  if (state.lift && state.base.isConnected) restoreLiftedElement(state.base, state.lift);
@@ -90880,6 +90890,7 @@ var StudioHeader = memo46(function StudioHeader2({
90880
90890
  import { useState as useState136, useCallback as useCallback164, useRef as useRef169, useEffect as useEffect136 } from "react";
90881
90891
 
90882
90892
  // src/hooks/useGestureRecording.ts
90893
+ import { revealClip as revealClip2 } from "@hyperframes/core/runtime/clip-reveal";
90883
90894
  import { useCallback as useCallback163, useEffect as useEffect135, useRef as useRef168, useState as useState135 } from "react";
90884
90895
  function readBasePosition(element, iframeEl) {
90885
90896
  let baseOpacity = 1;
@@ -90920,7 +90931,8 @@ function connectGsapRuntime(element, iframeEl, elementEndTime) {
90920
90931
  startTime: win.__player?.getTime() ?? 0,
90921
90932
  maxSeekTime: elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration,
90922
90933
  savedVisibility: element.style.visibility,
90923
- savedTranslate: element.style.getPropertyValue("translate")
90934
+ savedTranslate: element.style.getPropertyValue("translate"),
90935
+ putMarkBack: null
90924
90936
  };
90925
90937
  }
90926
90938
  } catch {
@@ -90932,7 +90944,8 @@ function applyRuntimePreview(runtime, time, properties) {
90932
90944
  runtime.timeline.seek(seekTime);
90933
90945
  runtime.element.style.setProperty("translate", "none");
90934
90946
  runtime.gsap.set(runtime.element, { ...properties });
90935
- runtime.element.style.visibility = "visible";
90947
+ const putMarkBack = revealClip2(runtime.element);
90948
+ runtime.putMarkBack ??= putMarkBack;
90936
90949
  liveTime.notify(seekTime);
90937
90950
  usePlayerStore.getState().setCurrentTime(seekTime);
90938
90951
  }
@@ -91008,6 +91021,7 @@ function releaseRuntimePreview(r) {
91008
91021
  if (!runtime) return;
91009
91022
  const { element, savedVisibility, savedTranslate } = runtime;
91010
91023
  element.style.visibility = savedVisibility;
91024
+ runtime.putMarkBack?.();
91011
91025
  element.style.setProperty("translate", savedTranslate || "");
91012
91026
  try {
91013
91027
  runtime.gsap.set(runtime.element, {
@@ -98684,7 +98698,7 @@ function levelOf(settings) {
98684
98698
  return settings.shutterDeg > STRONG_ABOVE ? "strong" : "subtle";
98685
98699
  }
98686
98700
  function patchForLevel(level) {
98687
- return level === "off" ? { enabled: false } : { enabled: true, ...LEVEL_SETTINGS[level] };
98701
+ return level === "off" ? { enabled: false, events: false } : { enabled: true, events: true, ...LEVEL_SETTINGS[level] };
98688
98702
  }
98689
98703
 
98690
98704
  // src/components/panels/MotionBlurPanel.tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.193",
3
+ "version": "0.8.195",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/parsers": "0.8.193",
64
- "@hyperframes/sdk": "0.8.193",
65
- "@hyperframes/core": "0.8.193",
66
- "@hyperframes/shader-transitions": "0.8.193",
67
- "@hyperframes/studio-server": "0.8.193",
68
- "@hyperframes/player": "0.8.193"
63
+ "@hyperframes/core": "0.8.195",
64
+ "@hyperframes/parsers": "0.8.195",
65
+ "@hyperframes/sdk": "0.8.195",
66
+ "@hyperframes/player": "0.8.195",
67
+ "@hyperframes/shader-transitions": "0.8.195",
68
+ "@hyperframes/studio-server": "0.8.195"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -82,7 +82,7 @@
82
82
  "vite": "^6.4.2",
83
83
  "vitest": "^4.1.11",
84
84
  "zustand": "^5.0.0",
85
- "@hyperframes/producer": "0.8.193"
85
+ "@hyperframes/producer": "0.8.195"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "react": "19",
@@ -18,6 +18,7 @@ import {
18
18
  readTimelineElementZIndex,
19
19
  } from "../../player/lib/timelineElementHelpers";
20
20
  import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
21
+ import { CLIP_HIDDEN_ATTR } from "@hyperframes/core/runtime/clip-reveal";
21
22
 
22
23
  installReactActEnvironment();
23
24
 
@@ -149,6 +150,33 @@ describe("useLayerRevealOverride — delayed reveal ownership", () => {
149
150
  act(() => root.unmount());
150
151
  });
151
152
 
153
+ it("lifts the runtime's hidden-clip mark with the visibility it forces, and puts both back", () => {
154
+ const host = document.createElement("div");
155
+ const clip = makeEl("2", "absolute");
156
+ clip.style.visibility = "hidden";
157
+ clip.setAttribute(CLIP_HIDDEN_ATTR, "");
158
+ document.body.appendChild(host);
159
+ const root = createRoot(host);
160
+ let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined;
161
+
162
+ function Harness({ isPlaying }: { isPlaying: boolean }) {
163
+ ({ scheduleReveal } = useLayerRevealOverride({ isPlaying, selectedElement: clip }));
164
+ return null;
165
+ }
166
+
167
+ act(() => root.render(React.createElement(Harness, { isPlaying: false })));
168
+ act(() => scheduleReveal!(clip, 0));
169
+ act(() => vi.advanceTimersByTime(0));
170
+ expect([clip.style.visibility, clip.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([
171
+ "visible",
172
+ false,
173
+ ]);
174
+
175
+ act(() => root.render(React.createElement(Harness, { isPlaying: true })));
176
+ expect([clip.style.visibility, clip.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual(["hidden", true]);
177
+ act(() => root.unmount());
178
+ });
179
+
152
180
  it("revalidates the current selection before a delayed reveal runs", () => {
153
181
  const host = document.createElement("div");
154
182
  const selected = makeEl("2", "absolute");
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
+ import { revealClip } from "@hyperframes/core/runtime/clip-reveal";
2
3
  import {
3
4
  LAYER_REVEAL_PRIOR_POSITION_ATTR,
4
5
  LAYER_REVEAL_PRIOR_Z_ATTR,
@@ -15,6 +16,7 @@ interface RevealedNode {
15
16
  priors: { display: string; visibility: string; opacity: string };
16
17
  /** Values THIS override wrote — restore only while they are still in place. */
17
18
  applied: { display?: string; visibility?: string; opacity?: string };
19
+ putMarkBack?: () => void;
18
20
  }
19
21
 
20
22
  interface RevealLift {
@@ -158,10 +160,11 @@ function restoreIfOurs(
158
160
  property: "display" | "visibility" | "opacity",
159
161
  applied: string | undefined,
160
162
  prior: string,
161
- ): void {
162
- if (applied == null) return;
163
- if (el.style.getPropertyValue(property) !== applied) return;
163
+ ): boolean {
164
+ if (applied == null) return false;
165
+ if (el.style.getPropertyValue(property) !== applied) return false;
164
166
  restoreInline(el, property, prior);
167
+ return true;
165
168
  }
166
169
 
167
170
  /** What hides this node at the current frame, per computed style. */
@@ -187,6 +190,7 @@ function revealNode(
187
190
  opacity: el.style.opacity,
188
191
  };
189
192
  const applied: RevealedNode["applied"] = {};
193
+ let putMarkBack: RevealedNode["putMarkBack"];
190
194
  if (needs.display) {
191
195
  // Prefer whatever the stylesheet says once the inline hide is lifted;
192
196
  // only force block when the sheet itself hides it.
@@ -195,14 +199,14 @@ function revealNode(
195
199
  applied.display = el.style.display;
196
200
  }
197
201
  if (needs.visibility) {
198
- el.style.visibility = "visible";
202
+ putMarkBack = revealClip(el);
199
203
  applied.visibility = "visible";
200
204
  }
201
205
  if (needs.opacity) {
202
206
  el.style.opacity = "1";
203
207
  applied.opacity = "1";
204
208
  }
205
- return { element: el, priors, applied };
209
+ return { element: el, priors, applied, putMarkBack };
206
210
  }
207
211
 
208
212
  /** Walk `element` → body, force-revealing every hiding node; returns the touched nodes. */
@@ -317,10 +321,11 @@ export function useLayerRevealOverride({
317
321
  const state = stateRef.current;
318
322
  stateRef.current = null;
319
323
  if (!state) return;
320
- for (const { element, priors, applied } of state.nodes) {
324
+ for (const { element, priors, applied, putMarkBack } of state.nodes) {
321
325
  if (!element.isConnected) continue;
322
326
  restoreIfOurs(element, "display", applied.display, priors.display);
323
- restoreIfOurs(element, "visibility", applied.visibility, priors.visibility);
327
+ if (restoreIfOurs(element, "visibility", applied.visibility, priors.visibility))
328
+ putMarkBack?.();
324
329
  restoreIfOurs(element, "opacity", applied.opacity, priors.opacity);
325
330
  }
326
331
  if (state.lift && state.base.isConnected) restoreLiftedElement(state.base, state.lift);
@@ -130,20 +130,29 @@ describe("MotionBlurPanel", () => {
130
130
  expect(html).toContain('data-motion-blur-samples="64"');
131
131
  });
132
132
 
133
- it("Off removes data-motion-blur and leaves every other setting on the root", async () => {
133
+ it("Off removes data-motion-blur and its events, and leaves the other settings on the root", async () => {
134
134
  const host = await mount(
135
135
  ' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64" data-motion-blur-tier="filter" data-motion-blur-events="on"',
136
136
  );
137
137
  await click(radio(host, "Off"));
138
138
  const html = files["index.html"] ?? "";
139
139
  expect(html).not.toContain('data-motion-blur="on"');
140
- for (const kept of ['shutter="720"', 'samples="64"', 'tier="filter"', 'events="on"']) {
140
+ expect(html).not.toContain("data-motion-blur-events");
141
+ for (const kept of ['shutter="720"', 'samples="64"', 'tier="filter"']) {
141
142
  expect(html).toContain(`data-motion-blur-${kept}`);
142
143
  }
143
144
  });
144
145
 
146
+ it("turns the events attribute on with Subtle and Strong, so callback-driven motion is found", async () => {
147
+ const host = await mount();
148
+ await click(radio(host, "Subtle"));
149
+ expect(files["index.html"]).toContain('data-motion-blur-events="on"');
150
+ await click(radio(host, "Strong"));
151
+ expect(files["index.html"]).toContain('data-motion-blur-events="on"');
152
+ });
153
+
145
154
  it("writes nothing when the chosen level is already set", async () => {
146
- const host = await mount(' data-motion-blur="on"');
155
+ const host = await mount(' data-motion-blur="on" data-motion-blur-events="on"');
147
156
  await click(radio(host, "Subtle"));
148
157
  expect(writeProjectFile).not.toHaveBeenCalled();
149
158
  });
@@ -130,9 +130,19 @@ describe("levelOf and patchForLevel", () => {
130
130
  });
131
131
 
132
132
  it("sets Subtle to the default blur and Strong to the maximum, and leaves the rest alone for Off", () => {
133
- expect(patchForLevel("subtle")).toEqual({ enabled: true, shutterDeg: 240, samples: null });
134
- expect(patchForLevel("strong")).toEqual({ enabled: true, shutterDeg: 720, samples: 64 });
135
- expect(patchForLevel("off")).toEqual({ enabled: false });
133
+ expect(patchForLevel("subtle")).toEqual({
134
+ enabled: true,
135
+ events: true,
136
+ shutterDeg: 240,
137
+ samples: null,
138
+ });
139
+ expect(patchForLevel("strong")).toEqual({
140
+ enabled: true,
141
+ events: true,
142
+ shutterDeg: 720,
143
+ samples: 64,
144
+ });
145
+ expect(patchForLevel("off")).toEqual({ enabled: false, events: false });
136
146
  });
137
147
 
138
148
  it("reads back as the level that was chosen", () => {
@@ -92,7 +92,9 @@ export function levelOf(settings: MotionBlurSettings): BlurLevel {
92
92
  return settings.shutterDeg > STRONG_ABOVE ? "strong" : "subtle";
93
93
  }
94
94
 
95
- /** What choosing `level` changes; Off leaves the shutter, samples and every other setting where they were. */
95
+ /** What choosing `level` changes; blur on turns events on too, and Off leaves the shutter, samples and the rest alone. */
96
96
  export function patchForLevel(level: BlurLevel): Partial<MotionBlurSettings> {
97
- return level === "off" ? { enabled: false } : { enabled: true, ...LEVEL_SETTINGS[level] };
97
+ return level === "off"
98
+ ? { enabled: false, events: false }
99
+ : { enabled: true, events: true, ...LEVEL_SETTINGS[level] };
98
100
  }
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { mountReactHarness } from "./domSelectionTestHarness";
6
6
  import { useGestureRecording, type GestureSample } from "./useGestureRecording";
7
+ import { CLIP_HIDDEN_ATTR } from "@hyperframes/core/runtime/clip-reveal";
7
8
 
8
9
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
10
 
@@ -65,6 +66,47 @@ describe("useGestureRecording", () => {
65
66
  act(() => root.unmount());
66
67
  });
67
68
 
69
+ it("lifts the runtime's hidden-clip mark while previewing a hidden clip and puts it back on release", () => {
70
+ const animationFrames: FrameRequestCallback[] = [];
71
+ vi.stubGlobal(
72
+ "requestAnimationFrame",
73
+ vi.fn((callback: FrameRequestCallback) => animationFrames.push(callback)),
74
+ );
75
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
76
+ const iframe = document.createElement("iframe");
77
+ document.body.append(iframe);
78
+ const element = iframe.contentDocument!.createElement("div");
79
+ element.id = "card";
80
+ element.style.visibility = "hidden";
81
+ element.setAttribute(CLIP_HIDDEN_ATTR, "");
82
+ iframe.contentDocument!.body.append(element);
83
+ Reflect.set(iframe.contentWindow!, "gsap", { getProperty: vi.fn(() => 0), set: vi.fn() });
84
+ Reflect.set(iframe.contentWindow!, "__timelines", {
85
+ main: { duration: () => 5, seek: vi.fn() },
86
+ });
87
+ Reflect.set(iframe.contentWindow!, "__player", { getTime: () => 1 });
88
+
89
+ let recording: ReturnType<typeof useGestureRecording> | null = null;
90
+ function Harness() {
91
+ recording = useGestureRecording();
92
+ return null;
93
+ }
94
+ const root = mountReactHarness(<Harness />);
95
+ act(() => recording?.startRecording(element, iframe, 4));
96
+ document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 10 }));
97
+ act(() => animationFrames.shift()?.(1));
98
+ expect([element.style.visibility, element.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([
99
+ "visible",
100
+ false,
101
+ ]);
102
+
103
+ act(() => root.unmount());
104
+ expect([element.style.visibility, element.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([
105
+ "hidden",
106
+ true,
107
+ ]);
108
+ });
109
+
68
110
  it("releases the preview runtime when unmounted during a recording", () => {
69
111
  const cancelAnimationFrame = vi.fn();
70
112
  vi.stubGlobal(
@@ -1,3 +1,4 @@
1
+ import { revealClip } from "@hyperframes/core/runtime/clip-reveal";
1
2
  import { useCallback, useEffect, useRef, useState } from "react";
2
3
  import { usePlayerStore, liveTime } from "../player/store/playerStore";
3
4
  import { frameToSeconds, secondsToFrame } from "../player/lib/time";
@@ -36,6 +37,7 @@ interface GsapRuntime {
36
37
  maxSeekTime: number;
37
38
  savedVisibility: string;
38
39
  savedTranslate: string;
40
+ putMarkBack: (() => void) | null;
39
41
  }
40
42
 
41
43
  // ---------------------------------------------------------------------------
@@ -107,6 +109,7 @@ function connectGsapRuntime(
107
109
  elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration,
108
110
  savedVisibility: element.style.visibility,
109
111
  savedTranslate: element.style.getPropertyValue("translate"),
112
+ putMarkBack: null,
110
113
  };
111
114
  }
112
115
  } catch {
@@ -124,7 +127,8 @@ function applyRuntimePreview(
124
127
  runtime.timeline.seek(seekTime);
125
128
  runtime.element.style.setProperty("translate", "none");
126
129
  runtime.gsap.set(runtime.element, { ...properties });
127
- runtime.element.style.visibility = "visible";
130
+ const putMarkBack = revealClip(runtime.element);
131
+ runtime.putMarkBack ??= putMarkBack;
128
132
  liveTime.notify(seekTime);
129
133
  usePlayerStore.getState().setCurrentTime(seekTime);
130
134
  }
@@ -251,6 +255,7 @@ function releaseRuntimePreview(r: RecordingRefs): void {
251
255
  if (!runtime) return;
252
256
  const { element, savedVisibility, savedTranslate } = runtime;
253
257
  element.style.visibility = savedVisibility;
258
+ runtime.putMarkBack?.();
254
259
  element.style.setProperty("translate", savedTranslate || "");
255
260
  try {
256
261
  runtime.gsap.set(runtime.element, {
@@ -318,6 +318,28 @@ window.__timelines["root"] = tl;`;
318
318
  expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
319
319
  });
320
320
 
321
+ it("loads a missing soft-reload plugin from the iframe mirror and reports its failure", () => {
322
+ const { iframe, contentWindow } = buildMockIframe({
323
+ gsap: { timeline: vi.fn(), registerPlugin: vi.fn(), version: "3.14.2" },
324
+ });
325
+ const head = iframe.contentDocument!.head;
326
+ const meta = document.createElement("meta");
327
+ meta.name = "hyperframes-gsap-base-url";
328
+ meta.content = "http://localhost:8080/vendor/npm";
329
+ head.appendChild(meta);
330
+ const onAsyncFailure = vi.fn();
331
+ expect(applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, { onAsyncFailure })).toBe("applied");
332
+ const plugin = head.querySelector("script");
333
+ expect(plugin?.src).toBe(
334
+ "http://localhost:8080/vendor/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js",
335
+ );
336
+ plugin?.onerror?.(new Event("error"));
337
+ expect(onAsyncFailure).toHaveBeenCalledTimes(1);
338
+ expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
339
+ expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
340
+ expect(head.querySelectorAll("script")).toHaveLength(1);
341
+ });
342
+
321
343
  it('returns "cannot-soft-reload" when multiple GSAP scripts exist (ambiguous)', () => {
322
344
  const script1 = document.createElement("script");
323
345
  script1.textContent = "const tl = gsap.timeline({ paused: true });";
@@ -490,6 +512,45 @@ describe("ensureMotionPathPluginLoaded", () => {
490
512
  );
491
513
  });
492
514
 
515
+ it("preloads from the iframe mirror once, retaining the runtime version", () => {
516
+ const { iframe, appendedScripts, contentWindow, registerPlugin } = buildBootstrapIframe({
517
+ gsap: { version: "3.14.2", registerPlugin: vi.fn() },
518
+ });
519
+ const meta = document.createElement("meta");
520
+ meta.name = "hyperframes-gsap-base-url";
521
+ meta.content = "http://localhost:8080/vendor/npm";
522
+ iframe.contentDocument!.head.appendChild(meta);
523
+ ensureMotionPathPluginLoaded(iframe);
524
+ ensureMotionPathPluginLoaded(iframe);
525
+ expect(appendedScripts).toHaveLength(1);
526
+ expect(appendedScripts[0]!.src).toBe(
527
+ "http://localhost:8080/vendor/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js",
528
+ );
529
+ appendedScripts[0]!.onerror?.(new Event("error"));
530
+ expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
531
+ expect(registerPlugin).not.toHaveBeenCalled();
532
+ expect(appendedScripts).toHaveLength(1);
533
+ });
534
+
535
+ it.each([
536
+ ["3.15.0", "/api/vendor/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js"],
537
+ ["3.14.2", "https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js"],
538
+ ])(
539
+ "preloads gsap %s from the host's bundled copy only when it holds that version",
540
+ (version, url) => {
541
+ const { iframe, appendedScripts } = buildBootstrapIframe({
542
+ gsap: { version, registerPlugin: vi.fn() },
543
+ });
544
+ const meta = document.createElement("meta");
545
+ meta.name = "hyperframes-gsap-base-url";
546
+ meta.content = "/api/vendor/npm";
547
+ iframe.contentDocument!.head.appendChild(meta);
548
+ ensureMotionPathPluginLoaded(iframe);
549
+ expect(appendedScripts).toHaveLength(1);
550
+ expect(appendedScripts[0]!.getAttribute("src")).toBe(url);
551
+ },
552
+ );
553
+
493
554
  it("appends the plugin script once and registers it on load", () => {
494
555
  const { iframe, contentWindow, appendedScripts, registerPlugin } = buildBootstrapIframe();
495
556
  ensureMotionPathPluginLoaded(iframe);
@@ -1,5 +1,5 @@
1
1
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
2
- import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
2
+ import { GSAP_BASE_URL_META, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
3
3
  import { findAuthoredElement } from "./authoredSource";
4
4
  import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
5
5
  import {
@@ -50,6 +50,13 @@ type IframeWindow = Window & {
50
50
  // At most one preview waits for its runtime; a newer load replaces it, so a frame that never boots is not kept alive.
51
51
  let pendingReadyRetry: ((event: MessageEvent) => void) | null = null;
52
52
 
53
+ function previewMotionPathUrl(doc: Document, version?: string): string {
54
+ const baseUrl = doc.head.querySelector<HTMLMetaElement>(
55
+ `meta[name="${GSAP_BASE_URL_META}"]`,
56
+ )?.content;
57
+ return motionPathPluginUrl(version, baseUrl);
58
+ }
59
+
53
60
  export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null): void {
54
61
  if (pendingReadyRetry) window.removeEventListener("message", pendingReadyRetry);
55
62
  pendingReadyRetry = null;
@@ -86,7 +93,7 @@ export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null):
86
93
  try {
87
94
  win.__hfMotionPathPluginLoading = true;
88
95
  const pluginScript = doc.createElement("script");
89
- pluginScript.src = motionPathPluginUrl(win.gsap?.version);
96
+ pluginScript.src = previewMotionPathUrl(doc, win.gsap?.version);
90
97
  const finalize = () => {
91
98
  win.__hfMotionPathPluginLoading = false;
92
99
  try {
@@ -480,7 +487,7 @@ export function applySoftReload(
480
487
  }
481
488
  win.__hfMotionPathPluginLoading = true;
482
489
  const pluginScript = doc.createElement("script");
483
- pluginScript.src = motionPathPluginUrl(win.gsap?.version);
490
+ pluginScript.src = previewMotionPathUrl(doc, win.gsap?.version);
484
491
  pluginScript.onload = () => {
485
492
  win.__hfMotionPathPluginLoading = false;
486
493
  executeScript();