@hyperframes/studio 0.6.122-alpha.0 → 0.7.1
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/assets/{index-i-MLqhmm.js → index-CnYOrmoA.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/assets/index-Z4bA3Fi0.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-wWkKTkIq.js} +24 -24
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3768 -2245
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -41
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditPreviewSync.ts +3 -0
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomEditWiring.ts +1 -0
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +12 -11
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-Baz2T-9U.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- package/src/player/components/TimelinePropertyRows.tsx +0 -126
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
// Mock the two preview-sync primitives so we can assert which path runCommit took.
|
|
7
|
+
// `patchRuntimeTweenInPlace` is the instant in-place patch; `applySoftReload` is
|
|
8
|
+
// the existing fallback. `extractGsapScriptText` is re-exported from the same
|
|
9
|
+
// module and used elsewhere in the hook — keep it a harmless stub.
|
|
10
|
+
const patchRuntimeTweenInPlace = vi.fn<(...args: unknown[]) => boolean>();
|
|
11
|
+
const applySoftReload = vi.fn<(...args: unknown[]) => string>();
|
|
12
|
+
const trackStudioEvent = vi.fn();
|
|
13
|
+
|
|
14
|
+
vi.mock("./gsapRuntimePatch", () => ({
|
|
15
|
+
patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args),
|
|
16
|
+
}));
|
|
17
|
+
vi.mock("../utils/gsapSoftReload", () => ({
|
|
18
|
+
applySoftReload: (...args: unknown[]) => applySoftReload(...args),
|
|
19
|
+
extractGsapScriptText: () => "",
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("../utils/studioTelemetry", () => ({
|
|
22
|
+
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// Tell React this is an act-capable environment so act(...) flushes effects
|
|
26
|
+
// without warning (React reads this global at call time).
|
|
27
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
28
|
+
|
|
29
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
30
|
+
import type { MutationResult } from "./gsapScriptCommitTypes";
|
|
31
|
+
import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
32
|
+
|
|
33
|
+
// ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
|
|
34
|
+
|
|
35
|
+
const FAKE_IFRAME = {} as HTMLIFrameElement;
|
|
36
|
+
|
|
37
|
+
function result(over: Partial<MutationResult> = {}): MutationResult {
|
|
38
|
+
return { ok: true, scriptText: "tl.set('#a',{})", ...over };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("applyPreviewSync", () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
patchRuntimeTweenInPlace.mockReset();
|
|
44
|
+
applySoftReload.mockReset();
|
|
45
|
+
trackStudioEvent.mockReset();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("instantPatch + patch succeeds: skips both soft reload and full reload", () => {
|
|
49
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
50
|
+
const reloadPreview = vi.fn();
|
|
51
|
+
|
|
52
|
+
applyPreviewSync(
|
|
53
|
+
FAKE_IFRAME,
|
|
54
|
+
result(),
|
|
55
|
+
{
|
|
56
|
+
label: "drag",
|
|
57
|
+
softReload: true,
|
|
58
|
+
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
59
|
+
},
|
|
60
|
+
reloadPreview,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
|
|
64
|
+
kind: "set",
|
|
65
|
+
props: { x: 10 },
|
|
66
|
+
});
|
|
67
|
+
expect(applySoftReload).not.toHaveBeenCalled();
|
|
68
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("instantPatch + patch fails: falls back to the soft reload, passing onAsyncFailure", () => {
|
|
72
|
+
patchRuntimeTweenInPlace.mockReturnValue(false);
|
|
73
|
+
applySoftReload.mockReturnValue("applied");
|
|
74
|
+
const reloadPreview = vi.fn();
|
|
75
|
+
|
|
76
|
+
applyPreviewSync(
|
|
77
|
+
FAKE_IFRAME,
|
|
78
|
+
result({ scriptText: "SCRIPT" }),
|
|
79
|
+
{
|
|
80
|
+
label: "drag",
|
|
81
|
+
softReload: true,
|
|
82
|
+
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
83
|
+
},
|
|
84
|
+
reloadPreview,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin
|
|
88
|
+
// CDN load failure escalates to a full reload — but it is NOT called eagerly.
|
|
89
|
+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
|
90
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
91
|
+
// A successful instant patch is the fast path; here it missed → fallback event.
|
|
92
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
93
|
+
"gsap_instant_patch_fallback",
|
|
94
|
+
expect.objectContaining({ selector: "#a" }),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('instantPatch + patch fails + soft reload "verify-failed": transient, does NOT escalate (U4)', () => {
|
|
99
|
+
patchRuntimeTweenInPlace.mockReturnValue(false);
|
|
100
|
+
applySoftReload.mockReturnValue("verify-failed");
|
|
101
|
+
const reloadPreview = vi.fn();
|
|
102
|
+
|
|
103
|
+
applyPreviewSync(
|
|
104
|
+
FAKE_IFRAME,
|
|
105
|
+
result({ scriptText: "SCRIPT" }),
|
|
106
|
+
{
|
|
107
|
+
label: "drag",
|
|
108
|
+
softReload: true,
|
|
109
|
+
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
110
|
+
},
|
|
111
|
+
reloadPreview,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state
|
|
115
|
+
// is correct, so we must NOT escalate to a full reload.
|
|
116
|
+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
|
117
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
118
|
+
// Telemetry records the suppressed transient (escalated: false).
|
|
119
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
120
|
+
"gsap_soft_reload_outcome",
|
|
121
|
+
expect.objectContaining({
|
|
122
|
+
origin: "preview_sync",
|
|
123
|
+
result: "verify-failed",
|
|
124
|
+
escalated: false,
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('instantPatch + patch fails + soft reload "cannot-soft-reload": escalates to full reload', () => {
|
|
130
|
+
patchRuntimeTweenInPlace.mockReturnValue(false);
|
|
131
|
+
applySoftReload.mockReturnValue("cannot-soft-reload");
|
|
132
|
+
const reloadPreview = vi.fn();
|
|
133
|
+
|
|
134
|
+
applyPreviewSync(
|
|
135
|
+
FAKE_IFRAME,
|
|
136
|
+
result({ scriptText: "SCRIPT" }),
|
|
137
|
+
{
|
|
138
|
+
label: "drag",
|
|
139
|
+
softReload: true,
|
|
140
|
+
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
141
|
+
},
|
|
142
|
+
reloadPreview,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Structural failure: the preview is genuinely stale/broken → full reload.
|
|
146
|
+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
|
147
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
149
|
+
"gsap_soft_reload_outcome",
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
origin: "preview_sync",
|
|
152
|
+
result: "cannot-soft-reload",
|
|
153
|
+
escalated: true,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("no instantPatch + softReload + scriptText: soft reloads, passing onAsyncFailure", () => {
|
|
159
|
+
applySoftReload.mockReturnValue("applied");
|
|
160
|
+
const reloadPreview = vi.fn();
|
|
161
|
+
|
|
162
|
+
applyPreviewSync(
|
|
163
|
+
FAKE_IFRAME,
|
|
164
|
+
result({ scriptText: "SCRIPT" }),
|
|
165
|
+
{ label: "x", softReload: true },
|
|
166
|
+
reloadPreview,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
|
170
|
+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
|
171
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
172
|
+
// "applied" emits no telemetry (only the failure paths do).
|
|
173
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('no instantPatch + softReload "verify-failed": transient, does NOT escalate (U4)', () => {
|
|
177
|
+
applySoftReload.mockReturnValue("verify-failed");
|
|
178
|
+
const reloadPreview = vi.fn();
|
|
179
|
+
|
|
180
|
+
applyPreviewSync(
|
|
181
|
+
FAKE_IFRAME,
|
|
182
|
+
result({ scriptText: "SCRIPT" }),
|
|
183
|
+
{ label: "x", softReload: true },
|
|
184
|
+
reloadPreview,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// onAsyncFailure is wired, but the transient result does not trigger it.
|
|
188
|
+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
|
189
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
190
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
191
|
+
"gsap_soft_reload_outcome",
|
|
192
|
+
expect.objectContaining({ result: "verify-failed", escalated: false }),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('no instantPatch + softReload "cannot-soft-reload": escalates to full reload', () => {
|
|
197
|
+
applySoftReload.mockReturnValue("cannot-soft-reload");
|
|
198
|
+
const reloadPreview = vi.fn();
|
|
199
|
+
|
|
200
|
+
applyPreviewSync(
|
|
201
|
+
FAKE_IFRAME,
|
|
202
|
+
result({ scriptText: "SCRIPT" }),
|
|
203
|
+
{ label: "x", softReload: true },
|
|
204
|
+
reloadPreview,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
|
208
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
209
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
210
|
+
"gsap_soft_reload_outcome",
|
|
211
|
+
expect.objectContaining({ result: "cannot-soft-reload", escalated: true }),
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("no instantPatch + no softReload: full reload (today's behavior)", () => {
|
|
216
|
+
const reloadPreview = vi.fn();
|
|
217
|
+
|
|
218
|
+
applyPreviewSync(FAKE_IFRAME, result(), { label: "x" }, reloadPreview);
|
|
219
|
+
|
|
220
|
+
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
|
221
|
+
expect(applySoftReload).not.toHaveBeenCalled();
|
|
222
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ── runCommit (full hook path: persist + preview sync) ───────────────────────
|
|
227
|
+
|
|
228
|
+
type HookApi = ReturnType<typeof useGsapScriptCommits>;
|
|
229
|
+
|
|
230
|
+
let cleanup: (() => void) | null = null;
|
|
231
|
+
|
|
232
|
+
function renderCommitHook() {
|
|
233
|
+
const reloadPreview = vi.fn();
|
|
234
|
+
const onCacheInvalidate = vi.fn();
|
|
235
|
+
const forceReloadSdkSession = vi.fn();
|
|
236
|
+
const recordEdit = vi.fn(async () => {});
|
|
237
|
+
const showToast = vi.fn();
|
|
238
|
+
|
|
239
|
+
const captured: { api: HookApi | null } = { api: null };
|
|
240
|
+
function Probe() {
|
|
241
|
+
captured.api = useGsapScriptCommits({
|
|
242
|
+
projectIdRef: { current: "proj-1" },
|
|
243
|
+
activeCompPath: "index.html",
|
|
244
|
+
previewIframeRef: { current: FAKE_IFRAME },
|
|
245
|
+
editHistory: { recordEdit },
|
|
246
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
247
|
+
reloadPreview,
|
|
248
|
+
onCacheInvalidate,
|
|
249
|
+
onFileContentChanged: undefined,
|
|
250
|
+
showToast,
|
|
251
|
+
sdkSession: null,
|
|
252
|
+
writeProjectFile: undefined,
|
|
253
|
+
forceReloadSdkSession,
|
|
254
|
+
});
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const container = document.createElement("div");
|
|
259
|
+
const root = createRoot(container);
|
|
260
|
+
act(() => {
|
|
261
|
+
root.render(<Probe />);
|
|
262
|
+
});
|
|
263
|
+
cleanup = () => act(() => root.unmount());
|
|
264
|
+
const hookApi = captured.api;
|
|
265
|
+
if (!hookApi) throw new Error("hook did not initialize");
|
|
266
|
+
return {
|
|
267
|
+
api: hookApi,
|
|
268
|
+
reloadPreview,
|
|
269
|
+
onCacheInvalidate,
|
|
270
|
+
forceReloadSdkSession,
|
|
271
|
+
recordEdit,
|
|
272
|
+
showToast,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const selection: DomEditSelection = { id: "a", selector: "#a" } as DomEditSelection;
|
|
277
|
+
|
|
278
|
+
function mockFetchResult(over: Partial<MutationResult> = {}): void {
|
|
279
|
+
const body: MutationResult = {
|
|
280
|
+
ok: true,
|
|
281
|
+
changed: true,
|
|
282
|
+
before: "BEFORE",
|
|
283
|
+
after: "AFTER",
|
|
284
|
+
scriptText: "SCRIPT",
|
|
285
|
+
...over,
|
|
286
|
+
};
|
|
287
|
+
vi.stubGlobal(
|
|
288
|
+
"fetch",
|
|
289
|
+
vi.fn(async () => ({ ok: true, json: async () => body }) as unknown as Response),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
describe("runCommit — instantPatch wiring", () => {
|
|
294
|
+
beforeEach(() => {
|
|
295
|
+
patchRuntimeTweenInPlace.mockReset();
|
|
296
|
+
applySoftReload.mockReset();
|
|
297
|
+
trackStudioEvent.mockReset();
|
|
298
|
+
});
|
|
299
|
+
afterEach(() => {
|
|
300
|
+
cleanup?.();
|
|
301
|
+
cleanup = null;
|
|
302
|
+
vi.unstubAllGlobals();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("instantPatch succeeds: persists, invalidates cache, NO reload", async () => {
|
|
306
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
307
|
+
mockFetchResult();
|
|
308
|
+
const deps = renderCommitHook();
|
|
309
|
+
|
|
310
|
+
await act(async () => {
|
|
311
|
+
await deps.api.commitMutation(
|
|
312
|
+
selection,
|
|
313
|
+
{ x: 10 },
|
|
314
|
+
{
|
|
315
|
+
label: "drag",
|
|
316
|
+
softReload: true,
|
|
317
|
+
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(fetch).toHaveBeenCalledTimes(1); // source mutation persisted
|
|
323
|
+
expect(deps.recordEdit).toHaveBeenCalledTimes(1);
|
|
324
|
+
expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(applySoftReload).not.toHaveBeenCalled();
|
|
326
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("instantPatch fails: persists AND falls back to soft reload", async () => {
|
|
330
|
+
patchRuntimeTweenInPlace.mockReturnValue(false);
|
|
331
|
+
applySoftReload.mockReturnValue("applied");
|
|
332
|
+
mockFetchResult();
|
|
333
|
+
const deps = renderCommitHook();
|
|
334
|
+
|
|
335
|
+
await act(async () => {
|
|
336
|
+
await deps.api.commitMutation(
|
|
337
|
+
selection,
|
|
338
|
+
{ x: 10 },
|
|
339
|
+
{
|
|
340
|
+
label: "drag",
|
|
341
|
+
softReload: true,
|
|
342
|
+
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
348
|
+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
|
|
349
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
350
|
+
expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("no instantPatch: identical to today — soft reload when softReload+scriptText", async () => {
|
|
354
|
+
applySoftReload.mockReturnValue("applied");
|
|
355
|
+
mockFetchResult();
|
|
356
|
+
const deps = renderCommitHook();
|
|
357
|
+
|
|
358
|
+
await act(async () => {
|
|
359
|
+
await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
|
363
|
+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
|
|
364
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { useCallback, useMemo, useRef } from "react";
|
|
2
|
+
import { editLog } from "../utils/editDebugLog";
|
|
2
3
|
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
3
4
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
5
|
import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
|
|
6
|
+
import type { SoftReloadResult } from "../utils/gsapSoftReload";
|
|
7
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
8
|
import type { CutoverDeps } from "../utils/sdkCutover";
|
|
6
9
|
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
|
|
10
|
+
import { patchRuntimeTweenInPlace } from "./gsapRuntimePatch";
|
|
7
11
|
import { createKeyedSerializer } from "./serializeByKey";
|
|
8
12
|
import {
|
|
9
13
|
GsapMutationHttpError,
|
|
@@ -43,6 +47,77 @@ async function mutateGsapScript(
|
|
|
43
47
|
return result;
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Apply a soft reload and enforce the U4 invariant via the richer
|
|
52
|
+
* `SoftReloadResult`, with telemetry on every non-success path so the invariant
|
|
53
|
+
* is observable in production, not just asserted in tests:
|
|
54
|
+
*
|
|
55
|
+
* - `"cannot-soft-reload"` (PERMANENT/STRUCTURAL: no gsap runtime, no rebind
|
|
56
|
+
* hook, no scopable key, no script element, or the sync re-run threw) →
|
|
57
|
+
* escalate to a full `reloadPreview()`; the preview is genuinely stale/broken.
|
|
58
|
+
* - `"verify-failed"` (TRANSIENT: re-run happened, `__timelines` momentarily
|
|
59
|
+
* empty) → do NOT escalate; the live `gsap.set` already shows the correct value
|
|
60
|
+
* and a remount would re-flash the WebGL context + revert subcomp keyframes.
|
|
61
|
+
* - `"applied"` → success (or deferred to async plugin load; `onAsyncFailure`
|
|
62
|
+
* covers the CDN-error escalation).
|
|
63
|
+
*/
|
|
64
|
+
function softReloadOrEscalate(
|
|
65
|
+
iframe: HTMLIFrameElement | null,
|
|
66
|
+
scriptText: string,
|
|
67
|
+
reloadPreview: () => void,
|
|
68
|
+
origin: "preview_sync" | "sdk_refresh",
|
|
69
|
+
): void {
|
|
70
|
+
const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview);
|
|
71
|
+
if (result === "applied") return;
|
|
72
|
+
trackStudioEvent("gsap_soft_reload_outcome", {
|
|
73
|
+
origin,
|
|
74
|
+
result,
|
|
75
|
+
escalated: result === "cannot-soft-reload",
|
|
76
|
+
});
|
|
77
|
+
// PERMANENT failure: the preview can't be soft-updated → full reload. TRANSIENT
|
|
78
|
+
// "verify-failed" is suppressed (live state is correct).
|
|
79
|
+
if (result === "cannot-soft-reload") reloadPreview();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sync the preview after a persisted commit. For a value-only edit
|
|
84
|
+
* (`options.instantPatch`), try the in-place runtime patch first: on success the
|
|
85
|
+
* preview is already correct, so we skip the reload entirely (instant). On `false`
|
|
86
|
+
* — or when no `instantPatch` is supplied — fall back to the existing soft/full
|
|
87
|
+
* reload. Pure (no React) so `runCommit`'s preview-sync decision is unit-testable.
|
|
88
|
+
*/
|
|
89
|
+
export function applyPreviewSync(
|
|
90
|
+
iframe: HTMLIFrameElement | null,
|
|
91
|
+
result: MutationResult,
|
|
92
|
+
options: CommitMutationOptions,
|
|
93
|
+
reloadPreview: () => void,
|
|
94
|
+
): void {
|
|
95
|
+
if (options.instantPatch) {
|
|
96
|
+
const patched = patchRuntimeTweenInPlace(
|
|
97
|
+
iframe,
|
|
98
|
+
options.instantPatch.selector,
|
|
99
|
+
options.instantPatch.change,
|
|
100
|
+
);
|
|
101
|
+
// Patched in place — element is already correct on screen; no reload needed.
|
|
102
|
+
if (patched) return;
|
|
103
|
+
// The instant path couldn't patch in place — record the fallback so we can
|
|
104
|
+
// track how often the fast path misses before the soft/full reload below.
|
|
105
|
+
trackStudioEvent("gsap_instant_patch_fallback", { selector: options.instantPatch.selector });
|
|
106
|
+
// Fall through to the soft/full reload path below.
|
|
107
|
+
}
|
|
108
|
+
if (options.softReload && result.scriptText) {
|
|
109
|
+
// A soft-reloadable edit escalates to a full iframe remount ONLY on the
|
|
110
|
+
// PERMANENT "cannot-soft-reload" result (the preview is genuinely stale/
|
|
111
|
+
// broken). The TRANSIENT "verify-failed" does NOT escalate — the value is
|
|
112
|
+
// already correct on screen, and a remount re-flashes the WebGL context AND
|
|
113
|
+
// re-inlines subcomps (reverting their keyframes). The async MotionPath-plugin
|
|
114
|
+
// load failure escalates separately via `onAsyncFailure`.
|
|
115
|
+
softReloadOrEscalate(iframe, result.scriptText, reloadPreview, "preview_sync");
|
|
116
|
+
} else {
|
|
117
|
+
reloadPreview();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
46
121
|
// oxfmt-ignore
|
|
47
122
|
// fallow-ignore-next-line complexity
|
|
48
123
|
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
|
|
@@ -55,6 +130,12 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
55
130
|
const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
56
131
|
const pid = projectIdRef.current;
|
|
57
132
|
if (!pid) return;
|
|
133
|
+
editLog("gsap-commit", {
|
|
134
|
+
type: mutation.type,
|
|
135
|
+
id: selection.id,
|
|
136
|
+
file: selection.sourceFile || activeCompPath,
|
|
137
|
+
label: options.label,
|
|
138
|
+
});
|
|
58
139
|
const unsafeFields = findUnsafeMutationValues(mutation);
|
|
59
140
|
if (unsafeFields.length > 0) {
|
|
60
141
|
showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
|
|
@@ -82,12 +163,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
82
163
|
if (options.skipReload) return;
|
|
83
164
|
if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation);
|
|
84
165
|
options.beforeReload?.();
|
|
85
|
-
|
|
86
|
-
if (!applySoftReload(previewIframeRef.current, result.scriptText)) reloadPreview();
|
|
87
|
-
} else {
|
|
88
|
-
reloadPreview();
|
|
89
|
-
}
|
|
166
|
+
applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
|
|
90
167
|
onCacheInvalidate();
|
|
168
|
+
editLog("gsap-commit:done", {
|
|
169
|
+
type: mutation.type,
|
|
170
|
+
changed: result.changed,
|
|
171
|
+
instant: Boolean(options.instantPatch),
|
|
172
|
+
});
|
|
91
173
|
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
|
|
92
174
|
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
93
175
|
// commits to the SAME file (any op type, any animation) interleave server-side,
|
|
@@ -109,8 +191,22 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
109
191
|
// the playhead) and invalidates the panel cache, matching the server path.
|
|
110
192
|
const sdkRefresh = useCallback(
|
|
111
193
|
(after: string) => {
|
|
194
|
+
// extractGsapScriptText returns null when zero/multiple GSAP scripts are
|
|
195
|
+
// present — that's an ambiguous/structural change that genuinely needs a full
|
|
196
|
+
// reload. But a SINGLE-script soft-reloadable edit must not escalate to a full
|
|
197
|
+
// remount even if applySoftReload reports failure (same U4 invariant as
|
|
198
|
+
// applyPreviewSync): the live state is already correct, and a remount re-inlines
|
|
199
|
+
// subcomps + reverts their keyframes.
|
|
112
200
|
const script = extractGsapScriptText(after);
|
|
113
|
-
if (
|
|
201
|
+
if (script) {
|
|
202
|
+
// Soft-reload in place. reloadPreview is the ASYNC-failure escalation — a
|
|
203
|
+
// plugin-CDN load error genuinely breaks the iframe → full reload. Per U4, a
|
|
204
|
+
// synchronous "verify-failed" (transient empty __timelines) does NOT escalate,
|
|
205
|
+
// but a "cannot-soft-reload" (structural failure) does.
|
|
206
|
+
softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh");
|
|
207
|
+
} else {
|
|
208
|
+
reloadPreview();
|
|
209
|
+
}
|
|
114
210
|
onCacheInvalidate();
|
|
115
211
|
},
|
|
116
212
|
[previewIframeRef, reloadPreview, onCacheInvalidate],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
2
2
|
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
|
4
|
+
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
|
|
4
5
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
5
6
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
|
6
7
|
import {
|
|
@@ -107,7 +108,12 @@ export async function fetchParsedAnimations(
|
|
|
107
108
|
const res = await fetch(
|
|
108
109
|
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
|
|
109
110
|
);
|
|
110
|
-
|
|
111
|
+
if (!res.ok) return null;
|
|
112
|
+
const parsed = (await res.json()) as ParsedGsap;
|
|
113
|
+
// Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
|
|
114
|
+
// hold an element's first keyframe before its tween). They must not surface as
|
|
115
|
+
// user animations — otherwise they pollute the keyframe cache / timeline diamonds.
|
|
116
|
+
return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) };
|
|
111
117
|
} catch {
|
|
112
118
|
return null;
|
|
113
119
|
}
|
|
@@ -131,7 +137,8 @@ export function useGsapAnimationsForElement(
|
|
|
131
137
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
132
138
|
|
|
133
139
|
useEffect(() => {
|
|
134
|
-
const
|
|
140
|
+
const targetKey = target?.id ?? target?.selector ?? "";
|
|
141
|
+
const fetchKey = `${projectId}:${sourceFile}:${version}:${targetKey}`;
|
|
135
142
|
if (fetchKey === lastFetchKeyRef.current) return;
|
|
136
143
|
lastFetchKeyRef.current = fetchKey;
|
|
137
144
|
|
|
@@ -360,9 +367,7 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
360
367
|
|
|
361
368
|
const sf = sourceFile;
|
|
362
369
|
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
|
363
|
-
if (!parsed)
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
370
|
+
if (!parsed) return;
|
|
366
371
|
const { setKeyframeCache } = usePlayerStore.getState();
|
|
367
372
|
clearKeyframeCacheForFile(sf);
|
|
368
373
|
const { elements } = usePlayerStore.getState();
|
|
@@ -370,13 +375,9 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
370
375
|
for (const anim of parsed.animations) {
|
|
371
376
|
const id = extractIdFromSelector(anim.targetSelector);
|
|
372
377
|
if (!id) continue;
|
|
373
|
-
if (anim.hasUnresolvedKeyframes)
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
378
|
+
if (anim.hasUnresolvedKeyframes) continue;
|
|
376
379
|
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
377
|
-
if (!kfData)
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
+
if (!kfData) continue;
|
|
380
381
|
const tweenPos =
|
|
381
382
|
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
382
383
|
const tweenDur = anim.duration ?? 1;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
+
import { editLog } from "../utils/editDebugLog";
|
|
2
3
|
import type { TimelineElement } from "../player";
|
|
3
4
|
import { usePlayerStore } from "../player";
|
|
4
5
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
@@ -38,13 +39,21 @@ async function splitHtmlElement(
|
|
|
38
39
|
patchTarget: NonNullable<ReturnType<typeof buildPatchTarget>>,
|
|
39
40
|
splitTime: number,
|
|
40
41
|
newId: string,
|
|
42
|
+
elementStart: number,
|
|
43
|
+
elementDuration: number,
|
|
41
44
|
): Promise<{ ok: boolean; changed?: boolean; content?: string }> {
|
|
42
45
|
const response = await fetch(
|
|
43
46
|
`/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
|
|
44
47
|
{
|
|
45
48
|
method: "POST",
|
|
46
49
|
headers: { "Content-Type": "application/json" },
|
|
47
|
-
body: JSON.stringify({
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
target: patchTarget,
|
|
52
|
+
splitTime,
|
|
53
|
+
newId,
|
|
54
|
+
elementStart,
|
|
55
|
+
elementDuration,
|
|
56
|
+
}),
|
|
48
57
|
},
|
|
49
58
|
);
|
|
50
59
|
if (!response.ok) throw new Error("Split request failed");
|
|
@@ -111,11 +120,21 @@ async function executeSplit(
|
|
|
111
120
|
if (!patchTarget) throw new Error("Clip is missing a patchable target.");
|
|
112
121
|
|
|
113
122
|
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
123
|
+
editLog("razor-split", { id: element.domId ?? element.id, splitTime, file: targetPath });
|
|
114
124
|
const originalContent = await readFileContent(pid, targetPath);
|
|
115
125
|
const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
|
|
116
126
|
|
|
117
|
-
const splitResult = await splitHtmlElement(
|
|
127
|
+
const splitResult = await splitHtmlElement(
|
|
128
|
+
pid,
|
|
129
|
+
targetPath,
|
|
130
|
+
patchTarget,
|
|
131
|
+
splitTime,
|
|
132
|
+
newId,
|
|
133
|
+
element.start,
|
|
134
|
+
element.duration,
|
|
135
|
+
);
|
|
118
136
|
if (!splitResult.ok) throw new Error("Failed to split clip.");
|
|
137
|
+
editLog("razor-split:done", { changed: splitResult.changed, newId });
|
|
119
138
|
if (!splitResult.changed) {
|
|
120
139
|
return { targetPath, originalContent, patchedContent: originalContent, changed: false };
|
|
121
140
|
}
|