@hyperframes/studio 0.6.109 → 0.6.111
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/hyperframes-player-BRjQ5a5_.js +418 -0
- package/dist/assets/index-B7S86vK1.js +370 -0
- package/dist/assets/index-DP8pPIk2.css +1 -0
- package/dist/assets/{index-SlMJloLR.js → index-_IV-vm9l.js} +1 -1
- package/dist/assets/{index-CFCe0Xvn.js → index-x0c2-zQN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/App.tsx +133 -141
- package/src/components/StudioHeader.tsx +44 -0
- package/src/components/StudioOverlays.tsx +70 -0
- package/src/components/editor/SourceEditor.tsx +19 -16
- package/src/components/editor/gsapAnimationConstants.ts +24 -0
- package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
- package/src/components/editor/manualEditingAvailability.ts +25 -7
- package/src/components/storyboard/FramePoster.tsx +56 -0
- package/src/components/storyboard/StoryboardDirection.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
- package/src/components/storyboard/StoryboardGrid.tsx +33 -0
- package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
- package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
- package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
- package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
- package/src/components/storyboard/StoryboardView.tsx +78 -0
- package/src/components/storyboard/frameStatus.ts +36 -0
- package/src/contexts/ViewModeContext.tsx +98 -0
- package/src/hooks/gsapScriptCommitTypes.ts +4 -7
- package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
- package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +20 -0
- package/src/hooks/useDomEditCommits.ts +43 -14
- package/src/hooks/useDomEditSession.test.ts +41 -0
- package/src/hooks/useDomEditSession.ts +38 -6
- package/src/hooks/useDomGeometryCommits.ts +5 -9
- package/src/hooks/useElementLifecycleOps.ts +30 -0
- package/src/hooks/useGsapAnimationOps.ts +78 -51
- package/src/hooks/useGsapKeyframeOps.ts +127 -43
- package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
- package/src/hooks/useGsapPropertyDebounce.ts +201 -23
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
- package/src/hooks/useGsapScriptCommits.ts +95 -40
- package/src/hooks/useGsapTweenCache.ts +0 -27
- package/src/hooks/useRazorSplit.ts +4 -10
- package/src/hooks/useSdkSession.test.ts +12 -0
- package/src/hooks/useSdkSession.ts +91 -25
- package/src/hooks/useStoryboard.ts +80 -0
- package/src/hooks/useTimelineEditing.ts +163 -68
- package/src/utils/gsapSoftReload.ts +14 -0
- package/src/utils/sdkCutover.gate.test.ts +61 -0
- package/src/utils/sdkCutover.test.ts +839 -0
- package/src/utils/sdkCutover.ts +465 -0
- package/src/utils/sdkOpMapping.ts +43 -0
- package/src/utils/sdkResolverShadow.test.ts +366 -0
- package/src/utils/sdkResolverShadow.ts +313 -0
- package/src/utils/timelineElementSplit.test.ts +61 -1
- package/src/utils/timelineElementSplit.ts +18 -0
- package/dist/assets/hyperframes-player-67pq7USK.js +0 -418
- package/dist/assets/index-BVqybwMG.css +0 -1
- package/dist/assets/index-ho_f4axK.js +0 -296
- package/src/utils/sdkShadow.test.ts +0 -606
- package/src/utils/sdkShadow.ts +0 -517
- package/src/utils/sdkShadowGsapFidelity.ts +0 -296
- package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
- package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
- package/src/utils/sdkShadowNumeric.ts +0 -11
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act, useState } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useGsapPropertyDebounce } from "./useGsapPropertyDebounce";
|
|
6
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
|
+
|
|
8
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
// The SDK path is gated on STUDIO_SDK_CUTOVER_ENABLED; keep it OFF so the flush
|
|
11
|
+
// routes through commitMutationSafely (the spy we count), keeping the test about
|
|
12
|
+
// flush TIMING, not the SDK write path.
|
|
13
|
+
vi.mock("../components/editor/manualEditingAvailability", () => ({
|
|
14
|
+
STUDIO_SDK_CUTOVER_ENABLED: false,
|
|
15
|
+
}));
|
|
16
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
17
|
+
|
|
18
|
+
const selection = { sourceFile: "index.html" } as unknown as DomEditSelection;
|
|
19
|
+
|
|
20
|
+
describe("useGsapPropertyDebounce flush stability (finding #7)", () => {
|
|
21
|
+
let container: HTMLDivElement;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.useFakeTimers();
|
|
24
|
+
container = document.createElement("div");
|
|
25
|
+
document.body.appendChild(container);
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
container.remove();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("re-rendering the parent while an edit is pending does NOT flush early or duplicate commits", () => {
|
|
33
|
+
const commitMutationSafely = vi.fn();
|
|
34
|
+
let queueEdit: (() => void) | null = null;
|
|
35
|
+
let forceRerender: (() => void) | null = null;
|
|
36
|
+
|
|
37
|
+
function Harness() {
|
|
38
|
+
const [tick, setTick] = useState(0);
|
|
39
|
+
forceRerender = () => setTick((t) => t + 1);
|
|
40
|
+
// A FRESH sdk wrapper literal every render — the exact churn that, before
|
|
41
|
+
// the ref-stabilization fix, re-fired the unmount-flush cleanup effect.
|
|
42
|
+
const ops = useGsapPropertyDebounce(commitMutationSafely, {
|
|
43
|
+
sdkSession: null,
|
|
44
|
+
sdkDeps: null,
|
|
45
|
+
activeCompPath: "index.html",
|
|
46
|
+
});
|
|
47
|
+
queueEdit = () => ops.updateGsapProperty(selection, "tw-1", "x", tick + 1);
|
|
48
|
+
return React.createElement("div", null, String(tick));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const root = createRoot(container);
|
|
52
|
+
act(() => {
|
|
53
|
+
root.render(React.createElement(Harness));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Queue one pending edit.
|
|
57
|
+
act(() => {
|
|
58
|
+
queueEdit?.();
|
|
59
|
+
});
|
|
60
|
+
expect(commitMutationSafely).not.toHaveBeenCalled();
|
|
61
|
+
|
|
62
|
+
// Re-render the parent several times BEFORE the debounce elapses. The bug
|
|
63
|
+
// flushed (and recorded a commit) on every re-render via the cleanup effect.
|
|
64
|
+
act(() => {
|
|
65
|
+
forceRerender?.();
|
|
66
|
+
});
|
|
67
|
+
act(() => {
|
|
68
|
+
forceRerender?.();
|
|
69
|
+
});
|
|
70
|
+
act(() => {
|
|
71
|
+
forceRerender?.();
|
|
72
|
+
});
|
|
73
|
+
expect(commitMutationSafely).not.toHaveBeenCalled();
|
|
74
|
+
|
|
75
|
+
// The debounce fires exactly once.
|
|
76
|
+
act(() => {
|
|
77
|
+
vi.advanceTimersByTime(200);
|
|
78
|
+
});
|
|
79
|
+
expect(commitMutationSafely).toHaveBeenCalledTimes(1);
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
root.unmount();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { useCallback, useRef } from "react";
|
|
1
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
2
2
|
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
|
-
import { applySoftReload } from "../utils/gsapSoftReload";
|
|
5
|
-
import {
|
|
6
|
-
import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe";
|
|
4
|
+
import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
|
|
5
|
+
import type { CutoverDeps } from "../utils/sdkCutover";
|
|
7
6
|
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
|
|
8
7
|
import { createKeyedSerializer } from "./serializeByKey";
|
|
9
8
|
import {
|
|
@@ -46,15 +45,12 @@ async function mutateGsapScript(
|
|
|
46
45
|
|
|
47
46
|
// oxfmt-ignore
|
|
48
47
|
// fallow-ignore-next-line complexity
|
|
49
|
-
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
|
|
48
|
+
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
|
|
50
49
|
// Serializer for per-key commits (options.serializeKey). Keyed by
|
|
51
50
|
// `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
|
|
52
|
-
// the same animationId so their POSTs can't interleave
|
|
53
|
-
//
|
|
54
|
-
// false ease mismatches. Held in a ref so the chain survives re-renders.
|
|
51
|
+
// the same animationId so their POSTs can't interleave. Held in a ref so the
|
|
52
|
+
// chain survives re-renders.
|
|
55
53
|
const serializerRef = useRef(createKeyedSerializer());
|
|
56
|
-
// Pre-existing complexity (server mutate + history + reload branches); this PR
|
|
57
|
-
// adds only a guarded shadow-fidelity dispatch.
|
|
58
54
|
// fallow-ignore-next-line complexity
|
|
59
55
|
const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
60
56
|
const pid = projectIdRef.current;
|
|
@@ -76,32 +72,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
76
72
|
}
|
|
77
73
|
if (result.changed === false) return;
|
|
78
74
|
domEditSaveTimestampRef.current = Date.now();
|
|
79
|
-
// Shadow value fidelity: diff the SDK's GSAP writer output against the
|
|
80
|
-
// server's, from the same pre-op file. Fire-and-forget; server authoritative.
|
|
81
|
-
// Meta-level ops carry shadowGsapOp (add / update-meta / delete via
|
|
82
|
-
// useGsapAnimationOps); keyframe ops carry shadowKeyframeOp (add/remove via
|
|
83
|
-
// useGsapKeyframeOps, handled by the gsap_keyframe block below). Per-property
|
|
84
|
-
// handlers (useGsapPropertyDebounce) don't synthesize one yet — deferred follow-up.
|
|
85
|
-
// scriptText is null when the composition has no GSAP script; nothing to diff.
|
|
86
|
-
const fidelityArgs = resolveGsapFidelityArgs(
|
|
87
|
-
sdkSession,
|
|
88
|
-
options.shadowGsapOp,
|
|
89
|
-
result.before,
|
|
90
|
-
result.scriptText,
|
|
91
|
-
);
|
|
92
|
-
if (fidelityArgs) {
|
|
93
|
-
void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
|
|
94
|
-
}
|
|
95
|
-
// Keyframe value fidelity (gsap_keyframe): same serialize-diff approach, but
|
|
96
|
-
// the SDK has no keyframe reader so there is no live-existence path — the diff
|
|
97
|
-
// is the only signal. Guarded on a live session + both scripts to diff.
|
|
98
|
-
if (sdkSession && options.shadowKeyframeOp && result.before != null && result.scriptText != null) {
|
|
99
|
-
void runShadowGsapKeyframeFidelity(result.before, options.shadowKeyframeOp, result.scriptText);
|
|
100
|
-
}
|
|
101
75
|
if (result.before != null && result.after != null) {
|
|
102
76
|
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
|
|
103
77
|
}
|
|
104
78
|
if (result.after != null) onFileContentChanged?.(targetPath, result.after);
|
|
79
|
+
// Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
|
|
80
|
+
// later SDK-routed edit doesn't serialize the pre-write doc and revert this.
|
|
81
|
+
forceReloadSdkSession?.();
|
|
105
82
|
if (options.skipReload) return;
|
|
106
83
|
if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation);
|
|
107
84
|
options.beforeReload?.();
|
|
@@ -111,12 +88,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
111
88
|
reloadPreview();
|
|
112
89
|
}
|
|
113
90
|
onCacheInvalidate();
|
|
114
|
-
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast,
|
|
91
|
+
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
|
|
115
92
|
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
116
|
-
// commits to the SAME file (any op type, any animation) interleave server-side
|
|
117
|
-
//
|
|
118
|
-
// the false ease/value mismatches this serializer exists to prevent. So
|
|
119
|
-
// serialize per target file by default; an explicit serializeKey overrides.
|
|
93
|
+
// commits to the SAME file (any op type, any animation) interleave server-side,
|
|
94
|
+
// so serialize per target file by default; an explicit serializeKey overrides.
|
|
120
95
|
const commitMutation = useCallback(
|
|
121
96
|
(selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
122
97
|
const file = selection.sourceFile || activeCompPath || "index.html";
|
|
@@ -127,9 +102,89 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
127
102
|
);
|
|
128
103
|
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
|
129
104
|
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
105
|
+
|
|
106
|
+
// One stable SDK-deps object shared by all GSAP child hooks. Memoized so the
|
|
107
|
+
// hooks' callbacks keep a stable identity (an inline literal here re-fired the
|
|
108
|
+
// property-debounce flush on every render). refresh() soft-reloads (preserving
|
|
109
|
+
// the playhead) and invalidates the panel cache, matching the server path.
|
|
110
|
+
const sdkRefresh = useCallback(
|
|
111
|
+
(after: string) => {
|
|
112
|
+
const script = extractGsapScriptText(after);
|
|
113
|
+
if (!(script && applySoftReload(previewIframeRef.current, script))) reloadPreview();
|
|
114
|
+
onCacheInvalidate();
|
|
115
|
+
},
|
|
116
|
+
[previewIframeRef, reloadPreview, onCacheInvalidate],
|
|
117
|
+
);
|
|
118
|
+
// Reuse the SAME per-file serializer the legacy commitMutation path uses, so
|
|
119
|
+
// SDK gsap-write flushes serialize against legacy commits AND each other —
|
|
120
|
+
// overlapping same-file read-modify-writes can't interleave and lose an edit.
|
|
121
|
+
const serializeByFile = useCallback(
|
|
122
|
+
<T>(key: string, task: () => Promise<T>): Promise<T> => serializerRef.current(key, task),
|
|
123
|
+
[],
|
|
124
|
+
);
|
|
125
|
+
// Read the on-disk bytes of targetPath so the SDK GSAP persist captures the
|
|
126
|
+
// exact prior content as its undo `before` (matching the style/delete paths),
|
|
127
|
+
// instead of a normalized full-DOM re-emit that would reformat the whole file.
|
|
128
|
+
const readProjectFileContent = useCallback(
|
|
129
|
+
async (path: string): Promise<string> => {
|
|
130
|
+
const pid = projectIdRef.current;
|
|
131
|
+
if (!pid) throw new Error("No active project");
|
|
132
|
+
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
133
|
+
if (!res.ok) throw new Error(`Failed to read ${path}`);
|
|
134
|
+
const data = (await res.json()) as { content?: string };
|
|
135
|
+
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
|
136
|
+
return data.content;
|
|
137
|
+
},
|
|
138
|
+
[projectIdRef],
|
|
139
|
+
);
|
|
140
|
+
const sdkDeps = useMemo<CutoverDeps | null>(
|
|
141
|
+
() =>
|
|
142
|
+
writeProjectFile
|
|
143
|
+
? {
|
|
144
|
+
editHistory: { recordEdit: editHistory.recordEdit },
|
|
145
|
+
writeProjectFile,
|
|
146
|
+
reloadPreview,
|
|
147
|
+
domEditSaveTimestampRef,
|
|
148
|
+
refresh: sdkRefresh,
|
|
149
|
+
compositionPath: activeCompPath,
|
|
150
|
+
serialize: serializeByFile,
|
|
151
|
+
readProjectFile: readProjectFileContent,
|
|
152
|
+
}
|
|
153
|
+
: null,
|
|
154
|
+
[
|
|
155
|
+
editHistory.recordEdit,
|
|
156
|
+
writeProjectFile,
|
|
157
|
+
reloadPreview,
|
|
158
|
+
domEditSaveTimestampRef,
|
|
159
|
+
sdkRefresh,
|
|
160
|
+
activeCompPath,
|
|
161
|
+
serializeByFile,
|
|
162
|
+
readProjectFileContent,
|
|
163
|
+
],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const propertyOps = useGsapPropertyDebounce(commitMutationSafely, {
|
|
167
|
+
sdkSession,
|
|
168
|
+
sdkDeps,
|
|
169
|
+
activeCompPath,
|
|
170
|
+
});
|
|
171
|
+
const animationOps = useGsapAnimationOps({
|
|
172
|
+
projectIdRef,
|
|
173
|
+
activeCompPath,
|
|
174
|
+
commitMutation,
|
|
175
|
+
commitMutationSafely,
|
|
176
|
+
showToast,
|
|
177
|
+
sdkSession,
|
|
178
|
+
sdkDeps,
|
|
179
|
+
});
|
|
180
|
+
const keyframeOps = useGsapKeyframeOps({
|
|
181
|
+
activeCompPath,
|
|
182
|
+
commitMutation,
|
|
183
|
+
commitMutationSafely,
|
|
184
|
+
trackGsapSaveFailure,
|
|
185
|
+
sdkSession,
|
|
186
|
+
sdkDeps,
|
|
187
|
+
});
|
|
133
188
|
const arcPathOps = useGsapArcPathOps(commitMutationSafely);
|
|
134
189
|
return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps };
|
|
135
190
|
}
|
|
@@ -366,13 +366,6 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
366
366
|
const { setKeyframeCache } = usePlayerStore.getState();
|
|
367
367
|
clearKeyframeCacheForFile(sf);
|
|
368
368
|
const { elements } = usePlayerStore.getState();
|
|
369
|
-
console.log(
|
|
370
|
-
"[kf:static] elements in store:",
|
|
371
|
-
elements
|
|
372
|
-
.map((e) => e.domId)
|
|
373
|
-
.filter(Boolean)
|
|
374
|
-
.join(", "),
|
|
375
|
-
);
|
|
376
369
|
const mergedByElement = new Map<string, GsapKeyframesData>();
|
|
377
370
|
for (const anim of parsed.animations) {
|
|
378
371
|
const id = extractIdFromSelector(anim.targetSelector);
|
|
@@ -415,12 +408,6 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
415
408
|
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
|
|
416
409
|
}
|
|
417
410
|
}
|
|
418
|
-
console.log(
|
|
419
|
-
"[kf:static] merged elements:",
|
|
420
|
-
[...mergedByElement.keys()].join(", "),
|
|
421
|
-
"kf counts:",
|
|
422
|
-
[...mergedByElement.entries()].map(([k, v]) => `${k}:${v.keyframes.length}`).join(", "),
|
|
423
|
-
);
|
|
424
411
|
for (const [id, kfData] of mergedByElement) {
|
|
425
412
|
setKeyframeCache(`${sf}#${id}`, kfData);
|
|
426
413
|
setKeyframeCache(id, kfData);
|
|
@@ -454,12 +441,6 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
454
441
|
if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
|
|
455
442
|
}
|
|
456
443
|
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
|
|
457
|
-
console.log(
|
|
458
|
-
"[kf:runtime] scanned",
|
|
459
|
-
scanned.size,
|
|
460
|
-
"elements:",
|
|
461
|
-
[...scanned.keys()].join(", "),
|
|
462
|
-
);
|
|
463
444
|
if (scanned.size === 0) return false;
|
|
464
445
|
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
|
|
465
446
|
for (const [id, data] of scanned) {
|
|
@@ -470,14 +451,6 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
470
451
|
if (alreadyCached) {
|
|
471
452
|
continue;
|
|
472
453
|
}
|
|
473
|
-
console.log(
|
|
474
|
-
"[kf:runtime] adding runtime entry:",
|
|
475
|
-
id,
|
|
476
|
-
"kfs:",
|
|
477
|
-
data.keyframes.length,
|
|
478
|
-
"arc:",
|
|
479
|
-
!!data.arcPath,
|
|
480
|
-
);
|
|
481
454
|
const entry = {
|
|
482
455
|
format: "percentage" as const,
|
|
483
456
|
keyframes: data.keyframes,
|
|
@@ -5,10 +5,10 @@ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
|
5
5
|
import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
|
|
6
6
|
import { trackStudioRazorSplit } from "../telemetry/events";
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
canSplitElementAt,
|
|
9
|
+
selectSplittableElements,
|
|
9
10
|
buildPatchTarget,
|
|
10
11
|
readFileContent,
|
|
11
|
-
isSplitTimeWithinBounds,
|
|
12
12
|
} from "../utils/timelineElementSplit";
|
|
13
13
|
import type { RecordEditInput } from "./timelineEditingHelpers";
|
|
14
14
|
|
|
@@ -170,11 +170,7 @@ export function useRazorSplit({
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
const pid = projectIdRef.current;
|
|
173
|
-
if (!pid || !
|
|
174
|
-
|
|
175
|
-
if (!isSplitTimeWithinBounds(splitTime, element.start, element.duration)) {
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
173
|
+
if (!pid || !canSplitElementAt(element, splitTime)) return;
|
|
178
174
|
|
|
179
175
|
try {
|
|
180
176
|
const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
|
|
@@ -232,9 +228,7 @@ export function useRazorSplit({
|
|
|
232
228
|
const pid = projectIdRef.current;
|
|
233
229
|
if (!pid) return;
|
|
234
230
|
const { elements } = usePlayerStore.getState();
|
|
235
|
-
const splittable = elements
|
|
236
|
-
(el) => canSplitElement(el) && splitTime > el.start && splitTime < el.start + el.duration,
|
|
237
|
-
);
|
|
231
|
+
const splittable = selectSplittableElements(elements, splitTime);
|
|
238
232
|
if (splittable.length === 0) return;
|
|
239
233
|
|
|
240
234
|
try {
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { shouldReloadSdkSession } from "./useSdkSession";
|
|
3
3
|
|
|
4
|
+
// ── undo-sync contract ────────────────────────────────────────────────────────
|
|
5
|
+
// useSdkSession exposes forceReload() so callers can bypass the 2 s self-write
|
|
6
|
+
// suppress window. useAppHotkeys calls forceReload() after a successful
|
|
7
|
+
// undo/redo that wrote the active composition path. Without it, the suppress
|
|
8
|
+
// window swallows the file-change event and the SDK session stays stale.
|
|
9
|
+
//
|
|
10
|
+
// The React hook internals (useState / useEffect) cannot be unit-tested without
|
|
11
|
+
// a full render environment; the correctness of the suppress-bypass path is
|
|
12
|
+
// covered by the integration tests in usePersistentEditHistory.test.ts
|
|
13
|
+
// (which verify undo writes the correct before-content to disk).
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
4
16
|
describe("shouldReloadSdkSession", () => {
|
|
5
17
|
it("reloads when the changed file is the active composition", () => {
|
|
6
18
|
expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import type { MutableRefObject } from "react";
|
|
2
3
|
import { openComposition } from "@hyperframes/sdk";
|
|
3
4
|
import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
|
|
4
5
|
import type { Composition } from "@hyperframes/sdk";
|
|
5
6
|
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
|
7
|
+
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* True when an external file-change payload targets the active composition and
|
|
@@ -20,33 +22,88 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
|
|
|
20
22
|
* (projectId, activeCompPath) change, disposes the old one on cleanup, and
|
|
21
23
|
* re-opens it when the active composition file changes on disk (code editor,
|
|
22
24
|
* agent, or server-side patch) so the in-memory linkedom document never goes
|
|
23
|
-
* stale.
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
* stale. The session has NO persist queue — Studio is the sole file writer; see
|
|
26
|
+
* the open effect below.
|
|
27
|
+
*/
|
|
28
|
+
// Reload-suppression baseline: a file-change within this window of our own SDK
|
|
29
|
+
// cutover write is a CANDIDATE echo, but the decision is content-identity based
|
|
30
|
+
// (isSelfWriteEcho) not time-only — so an undo write that lands inside the window
|
|
31
|
+
// still reloads (its reverted bytes were never registered as a self-write). The
|
|
32
|
+
// window only bounds how long a registered self-write stays suppressible.
|
|
33
|
+
const SELF_WRITE_SUPPRESS_MS = 2000;
|
|
34
|
+
|
|
35
|
+
/** Best-effort read of the changed file's content from a file-change payload. */
|
|
36
|
+
function readFileChangeContent(payload: unknown): string | null {
|
|
37
|
+
if (!payload || typeof payload !== "object") return null;
|
|
38
|
+
const record = payload as Record<string, unknown>;
|
|
39
|
+
if (typeof record.content === "string") return record.content;
|
|
40
|
+
if ("data" in record) return readFileChangeContent(record.data);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decide whether a file-change for the active composition should reload the SDK
|
|
46
|
+
* session. `content` is the new on-disk bytes (from the payload or a re-read);
|
|
47
|
+
* pass null when unavailable. Content-identity wins: a change whose bytes match a
|
|
48
|
+
* registered self-write is our own echo (suppress). Without content we can't prove
|
|
49
|
+
* identity, so we fall back to the time window ONLY to suppress an echo — an undo
|
|
50
|
+
* write outside the window (or any non-self-write) still reloads. Exported for test.
|
|
32
51
|
*/
|
|
52
|
+
export function shouldReloadOnFileChange(
|
|
53
|
+
activeCompPath: string,
|
|
54
|
+
content: string | null,
|
|
55
|
+
withinSuppressWindow: boolean,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (content != null) return !isSelfWriteEcho(activeCompPath, content);
|
|
58
|
+
// No content to compare — preserve the old time-window echo suppression.
|
|
59
|
+
return !withinSuppressWindow;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SdkSessionHandle {
|
|
63
|
+
session: Composition | null;
|
|
64
|
+
/**
|
|
65
|
+
* Force a session reload immediately, bypassing the self-write suppress
|
|
66
|
+
* window. Call after undo/redo writes the active composition file so the
|
|
67
|
+
* SDK in-memory document reflects the reverted content.
|
|
68
|
+
*/
|
|
69
|
+
forceReload: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
export function useSdkSession(
|
|
34
73
|
projectId: string | null,
|
|
35
74
|
activeCompPath: string | null,
|
|
36
|
-
|
|
75
|
+
domEditSaveTimestampRef?: MutableRefObject<number>,
|
|
76
|
+
): SdkSessionHandle {
|
|
37
77
|
const [session, setSession] = useState<Composition | null>(null);
|
|
38
78
|
const [reloadToken, setReloadToken] = useState(0);
|
|
39
79
|
|
|
40
80
|
// ── Re-open on external change to the active composition ──
|
|
41
81
|
useEffect(() => {
|
|
42
82
|
if (!activeCompPath) return;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
83
|
+
const compPath = activeCompPath;
|
|
84
|
+
const readAdapter =
|
|
85
|
+
projectId != null
|
|
86
|
+
? createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}` })
|
|
87
|
+
: null;
|
|
46
88
|
const handler = (payload?: unknown) => {
|
|
47
|
-
if (shouldReloadSdkSession(payload,
|
|
48
|
-
|
|
89
|
+
if (!shouldReloadSdkSession(payload, compPath)) return;
|
|
90
|
+
const withinWindow =
|
|
91
|
+
!!domEditSaveTimestampRef &&
|
|
92
|
+
Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS;
|
|
93
|
+
const decide = (content: string | null) => {
|
|
94
|
+
if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1);
|
|
95
|
+
};
|
|
96
|
+
const payloadContent = readFileChangeContent(payload);
|
|
97
|
+
// Prefer payload content; otherwise re-read so the decision is by IDENTITY
|
|
98
|
+
// (an undo's reverted bytes won't match a registered self-write → reload).
|
|
99
|
+
if (payloadContent != null || !readAdapter) {
|
|
100
|
+
decide(payloadContent);
|
|
101
|
+
return;
|
|
49
102
|
}
|
|
103
|
+
readAdapter
|
|
104
|
+
.read(compPath)
|
|
105
|
+
.then((c) => decide(typeof c === "string" ? c : null))
|
|
106
|
+
.catch(() => decide(null));
|
|
50
107
|
};
|
|
51
108
|
if (import.meta.hot) {
|
|
52
109
|
import.meta.hot.on("hf:file-change", handler);
|
|
@@ -56,7 +113,8 @@ export function useSdkSession(
|
|
|
56
113
|
const es = new EventSource("/api/events");
|
|
57
114
|
es.addEventListener("file-change", handler);
|
|
58
115
|
return () => es.close();
|
|
59
|
-
|
|
116
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
117
|
+
}, [activeCompPath, projectId]);
|
|
60
118
|
|
|
61
119
|
// ── Open / re-open the session ──
|
|
62
120
|
useEffect(() => {
|
|
@@ -66,7 +124,7 @@ export function useSdkSession(
|
|
|
66
124
|
}
|
|
67
125
|
|
|
68
126
|
let cancelled = false;
|
|
69
|
-
|
|
127
|
+
const compRef = { current: null as Composition | null };
|
|
70
128
|
|
|
71
129
|
const adapter = createHttpAdapter({
|
|
72
130
|
projectFilesUrl: `/api/projects/${projectId}`,
|
|
@@ -75,15 +133,21 @@ export function useSdkSession(
|
|
|
75
133
|
.read(activeCompPath)
|
|
76
134
|
.then(async (content) => {
|
|
77
135
|
if (cancelled || typeof content !== "string") return;
|
|
78
|
-
// No persist
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
136
|
+
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
137
|
+
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
138
|
+
// queue too would double-write the file (queue auto-writes on every
|
|
139
|
+
// 'change' AND Studio writes explicitly) and race on disk; it would
|
|
140
|
+
// also write the full active-composition serialization to the fixed
|
|
141
|
+
// persistPath even when an edit targeted a sub-composition file.
|
|
142
|
+
// Studio's editHistory is the authoritative undo stack — SDK history
|
|
143
|
+
// is unused dead weight here (forceReloadSdkSession discards it on undo).
|
|
144
|
+
const comp = await openComposition(content, { history: false });
|
|
82
145
|
// Cleanup may have fired while openComposition was awaited; dispose immediately.
|
|
83
146
|
if (cancelled) {
|
|
84
147
|
comp.dispose();
|
|
85
148
|
return;
|
|
86
149
|
}
|
|
150
|
+
compRef.current = comp;
|
|
87
151
|
setSession(comp);
|
|
88
152
|
})
|
|
89
153
|
.catch(() => {
|
|
@@ -92,10 +156,12 @@ export function useSdkSession(
|
|
|
92
156
|
|
|
93
157
|
return () => {
|
|
94
158
|
cancelled = true;
|
|
95
|
-
|
|
96
|
-
|
|
159
|
+
// No queue to flush; dispose only. (Flushing here would serialize the
|
|
160
|
+
// pre-undo in-memory doc and race the revert write on undo/redo reload.)
|
|
161
|
+
compRef.current?.dispose();
|
|
97
162
|
};
|
|
98
163
|
}, [projectId, activeCompPath, reloadToken]);
|
|
99
164
|
|
|
100
|
-
|
|
165
|
+
const forceReload = useCallback(() => setReloadToken((t) => t + 1), []);
|
|
166
|
+
return { session, forceReload };
|
|
101
167
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
StoryboardFrame,
|
|
4
|
+
StoryboardGlobals,
|
|
5
|
+
StoryboardWarning,
|
|
6
|
+
} from "@hyperframes/core/storyboard";
|
|
7
|
+
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
8
|
+
|
|
9
|
+
/** A frame as returned by the API: parsed frame + disk-resolution info. */
|
|
10
|
+
export interface StoryboardFrameView extends StoryboardFrame {
|
|
11
|
+
/** Whether `src` resolves to an existing file inside the project. */
|
|
12
|
+
srcExists: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The companion narration script (SCRIPT.md), when present alongside the storyboard. */
|
|
16
|
+
export interface StoryboardScript {
|
|
17
|
+
exists: boolean;
|
|
18
|
+
path: string;
|
|
19
|
+
content: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Shape of `GET /api/projects/:id/storyboard`. */
|
|
23
|
+
export interface StoryboardResponse {
|
|
24
|
+
exists: boolean;
|
|
25
|
+
path: string;
|
|
26
|
+
globals: StoryboardGlobals;
|
|
27
|
+
frames: StoryboardFrameView[];
|
|
28
|
+
warnings: StoryboardWarning[];
|
|
29
|
+
script?: StoryboardScript;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UseStoryboardResult {
|
|
33
|
+
data: StoryboardResponse | null;
|
|
34
|
+
loading: boolean;
|
|
35
|
+
error: string | null;
|
|
36
|
+
reload: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load the parsed storyboard manifest for a project. Markdown stays canonical on
|
|
41
|
+
* disk; this fetches the server-derived JSON the storyboard view renders.
|
|
42
|
+
*/
|
|
43
|
+
export function useStoryboard(projectId: string | null): UseStoryboardResult {
|
|
44
|
+
const [data, setData] = useState<StoryboardResponse | null>(null);
|
|
45
|
+
const [loading, setLoading] = useState(true);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
48
|
+
|
|
49
|
+
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!projectId) return;
|
|
53
|
+
let cancelled = false;
|
|
54
|
+
setLoading(true);
|
|
55
|
+
setError(null);
|
|
56
|
+
|
|
57
|
+
// Route through buildProjectApiPath so the (URL-derived) projectId is encoded
|
|
58
|
+
// into the path rather than interpolated raw (CodeQL js/client-side-request-forgery).
|
|
59
|
+
fetch(buildProjectApiPath(projectId, "/storyboard"))
|
|
60
|
+
.then((res) => {
|
|
61
|
+
if (!res.ok) throw new Error(`storyboard request failed: ${res.status}`);
|
|
62
|
+
return res.json() as Promise<StoryboardResponse>;
|
|
63
|
+
})
|
|
64
|
+
.then((json) => {
|
|
65
|
+
if (!cancelled) setData(json);
|
|
66
|
+
})
|
|
67
|
+
.catch((err: unknown) => {
|
|
68
|
+
if (!cancelled) setError(err instanceof Error ? err.message : "failed to load storyboard");
|
|
69
|
+
})
|
|
70
|
+
.finally(() => {
|
|
71
|
+
if (!cancelled) setLoading(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
cancelled = true;
|
|
76
|
+
};
|
|
77
|
+
}, [projectId, reloadKey]);
|
|
78
|
+
|
|
79
|
+
return { data, loading, error, reload };
|
|
80
|
+
}
|