@hyperframes/studio 0.6.100 → 0.6.102
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-BITwbxi-.css +1 -0
- package/dist/assets/{index-CKWBqyRd.js → index-BZKngETE.js} +1 -1
- package/dist/assets/index-BzjItfjX.js +296 -0
- package/dist/assets/{index-gpSohHUn.js → index-C0vMHtMH.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/App.tsx +2 -1
- package/src/components/editor/PropertyPanel.tsx +24 -16
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/nle/NLELayout.tsx +89 -1
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +121 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +48 -2
- package/src/hooks/gsapScriptCommitHelpers.ts +8 -5
- package/src/hooks/gsapScriptCommitTypes.ts +6 -0
- package/src/hooks/gsapTargetCache.ts +65 -0
- package/src/hooks/useAppHotkeys.ts +10 -0
- package/src/hooks/useDomEditCommits.ts +6 -5
- package/src/hooks/useDomEditSession.ts +6 -1
- package/src/hooks/useDomGeometryCommits.ts +1 -36
- package/src/hooks/useElementLifecycleOps.ts +5 -0
- package/src/hooks/useGsapAnimationOps.ts +46 -9
- package/src/hooks/useGsapScriptCommits.ts +22 -3
- package/src/hooks/useGsapTweenCache.ts +10 -12
- package/src/hooks/useRazorSplit.ts +3 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +1 -14
- package/src/hooks/useSdkSession.ts +15 -12
- package/src/hooks/useTimelineEditing.ts +23 -3
- package/src/player/components/Timeline.tsx +31 -18
- package/src/player/components/TimelineClip.tsx +3 -3
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +30 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +91 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +153 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +22 -0
- package/src/player/store/playerStore.ts +22 -8
- package/src/telemetry/events.test.ts +16 -1
- package/src/telemetry/events.ts +15 -0
- package/src/utils/blockCategories.ts +2 -2
- package/src/utils/sdkShadow.test.ts +232 -2
- package/src/utils/sdkShadow.ts +230 -2
- package/src/utils/sdkShadowGsapFidelity.ts +208 -0
- package/src/utils/studioHelpers.test.ts +25 -1
- package/src/utils/studioHelpers.ts +54 -28
- package/dist/assets/index-B62bDCQv.css +0 -1
- package/dist/assets/index-BkT9VKwz.js +0 -296
|
@@ -9,7 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
|
|
|
9
9
|
import { useDomSelection } from "./useDomSelection";
|
|
10
10
|
import { usePreviewInteraction } from "./usePreviewInteraction";
|
|
11
11
|
import { useDomEditCommits } from "./useDomEditCommits";
|
|
12
|
-
import { runShadowDispatch } from "../utils/sdkShadow";
|
|
12
|
+
import { runShadowDispatch, runShadowDelete } from "../utils/sdkShadow";
|
|
13
13
|
import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
14
14
|
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
|
15
15
|
import { useDomEditWiring } from "./useDomEditWiring";
|
|
@@ -194,6 +194,7 @@ export function useDomEditSession({
|
|
|
194
194
|
onCacheInvalidate: bumpGsapCache,
|
|
195
195
|
onFileContentChanged: updateEditingFileContent,
|
|
196
196
|
showToast,
|
|
197
|
+
sdkSession,
|
|
197
198
|
});
|
|
198
199
|
|
|
199
200
|
// ── DOM commit handlers ──
|
|
@@ -235,6 +236,7 @@ export function useDomEditSession({
|
|
|
235
236
|
onDomEditPersisted: sdkSession
|
|
236
237
|
? (sel, ops) => runShadowDispatch(sdkSession, sel, ops)
|
|
237
238
|
: undefined,
|
|
239
|
+
onElementDeleted: sdkSession ? (sel) => runShadowDelete(sdkSession, sel.hfId) : undefined,
|
|
238
240
|
});
|
|
239
241
|
|
|
240
242
|
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
|
@@ -263,6 +265,9 @@ export function useDomEditSession({
|
|
|
263
265
|
handleGsapRemoveAllKeyframes,
|
|
264
266
|
handleResetSelectedElementKeyframes,
|
|
265
267
|
} = useDomEditWiring({
|
|
268
|
+
// Pre-existing prop-drilling clone (same param set forwarded to
|
|
269
|
+
// useDomEditWiring); surfaced by this PR's adjacent edits, not introduced.
|
|
270
|
+
// fallow-ignore-next-line code-duplication
|
|
266
271
|
projectId,
|
|
267
272
|
activeCompPath,
|
|
268
273
|
domEditSelection,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
-
import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
3
2
|
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
|
|
4
3
|
import {
|
|
5
4
|
applyStudioPathOffset,
|
|
@@ -19,45 +18,11 @@ import {
|
|
|
19
18
|
} from "../components/editor/manualEditsDomPatches";
|
|
20
19
|
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
21
20
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
21
|
+
import { isElementGsapTargeted } from "./gsapTargetCache";
|
|
22
22
|
|
|
23
23
|
export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
|
|
24
24
|
"This element is GSAP-animated — dragging via CSS would corrupt keyframes";
|
|
25
25
|
|
|
26
|
-
// ── Helpers ──
|
|
27
|
-
|
|
28
|
-
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
|
|
29
|
-
|
|
30
|
-
// fallow-ignore-next-line complexity
|
|
31
|
-
function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean {
|
|
32
|
-
// When the GSAP drag intercept is disabled for debugging, treat every
|
|
33
|
-
// element as un-targeted so commits take the plain CSS persist path.
|
|
34
|
-
if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false;
|
|
35
|
-
if (!iframe?.contentWindow) return false;
|
|
36
|
-
let timelines: Record<string, TimelineLike> | undefined;
|
|
37
|
-
try {
|
|
38
|
-
timelines = (iframe.contentWindow as Window & { __timelines?: Record<string, TimelineLike> })
|
|
39
|
-
.__timelines;
|
|
40
|
-
} catch {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
if (!timelines) return false;
|
|
44
|
-
const id = element.id;
|
|
45
|
-
for (const tl of Object.values(timelines)) {
|
|
46
|
-
if (!tl?.getChildren) continue;
|
|
47
|
-
try {
|
|
48
|
-
for (const child of tl.getChildren(true)) {
|
|
49
|
-
if (!child.targets) continue;
|
|
50
|
-
for (const t of child.targets()) {
|
|
51
|
-
if (t === element || (id && t.id === id)) return true;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
} catch {
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
26
|
// ── Hook ──
|
|
62
27
|
|
|
63
28
|
interface UseDomGeometryCommitsParams {
|
|
@@ -31,6 +31,8 @@ interface UseElementLifecycleOpsParams {
|
|
|
31
31
|
patches: PatchOperation[],
|
|
32
32
|
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
|
|
33
33
|
) => Promise<void>;
|
|
34
|
+
/** Stage 7 Step 3b: called after a successful server-side element delete (shadow). */
|
|
35
|
+
onElementDeleted?: (selection: DomEditSelection) => void;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export function useElementLifecycleOps({
|
|
@@ -43,6 +45,7 @@ export function useElementLifecycleOps({
|
|
|
43
45
|
reloadPreview,
|
|
44
46
|
clearDomSelection,
|
|
45
47
|
commitPositionPatchToHtml,
|
|
48
|
+
onElementDeleted,
|
|
46
49
|
}: UseElementLifecycleOpsParams) {
|
|
47
50
|
// fallow-ignore-next-line complexity
|
|
48
51
|
const handleDomEditElementDelete = useCallback(
|
|
@@ -103,6 +106,7 @@ export function useElementLifecycleOps({
|
|
|
103
106
|
clearDomSelection();
|
|
104
107
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
105
108
|
reloadPreview();
|
|
109
|
+
onElementDeleted?.(selection);
|
|
106
110
|
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
|
107
111
|
} catch (error) {
|
|
108
112
|
const message = error instanceof Error ? error.message : "Failed to delete element";
|
|
@@ -114,6 +118,7 @@ export function useElementLifecycleOps({
|
|
|
114
118
|
clearDomSelection,
|
|
115
119
|
domEditSaveTimestampRef,
|
|
116
120
|
editHistory.recordEdit,
|
|
121
|
+
onElementDeleted,
|
|
117
122
|
projectIdRef,
|
|
118
123
|
reloadPreview,
|
|
119
124
|
showToast,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
2
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
4
|
import { roundTo3 } from "../utils/rounding";
|
|
5
|
+
import { runShadowGsapTween, type ShadowGsapOp } from "../utils/sdkShadow";
|
|
4
6
|
import {
|
|
5
7
|
assignGsapTargetAutoIdIfNeeded,
|
|
6
8
|
ensureElementAddressable,
|
|
@@ -13,6 +15,8 @@ interface GsapAnimationOpsParams {
|
|
|
13
15
|
commitMutation: CommitMutation;
|
|
14
16
|
commitMutationSafely: SafeGsapCommitMutation;
|
|
15
17
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
18
|
+
/** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
|
|
19
|
+
sdkSession?: Composition | null;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export function useGsapAnimationOps({
|
|
@@ -21,6 +25,7 @@ export function useGsapAnimationOps({
|
|
|
21
25
|
commitMutation,
|
|
22
26
|
commitMutationSafely,
|
|
23
27
|
showToast,
|
|
28
|
+
sdkSession,
|
|
24
29
|
}: GsapAnimationOpsParams) {
|
|
25
30
|
const updateGsapMeta = useCallback(
|
|
26
31
|
(
|
|
@@ -28,27 +33,34 @@ export function useGsapAnimationOps({
|
|
|
28
33
|
animationId: string,
|
|
29
34
|
updates: { duration?: number; ease?: string; position?: number },
|
|
30
35
|
) => {
|
|
36
|
+
// Shadow op (server animationId shares the SDK id-space): existence via
|
|
37
|
+
// runShadowGsapTween (live session) + value fidelity via the chokepoint.
|
|
38
|
+
const shadowGsapOp: ShadowGsapOp = {
|
|
39
|
+
kind: "set",
|
|
40
|
+
animationId,
|
|
41
|
+
properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
|
|
42
|
+
};
|
|
31
43
|
commitMutationSafely(
|
|
32
44
|
selection,
|
|
33
45
|
{ type: "update-meta", animationId, updates },
|
|
34
|
-
{
|
|
35
|
-
label: "Edit GSAP animation",
|
|
36
|
-
coalesceKey: `gsap:${animationId}:meta`,
|
|
37
|
-
},
|
|
46
|
+
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp },
|
|
38
47
|
);
|
|
48
|
+
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
39
49
|
},
|
|
40
|
-
[commitMutationSafely],
|
|
50
|
+
[commitMutationSafely, sdkSession],
|
|
41
51
|
);
|
|
42
52
|
|
|
43
53
|
const deleteGsapAnimation = useCallback(
|
|
44
54
|
(selection: DomEditSelection, animationId: string) => {
|
|
55
|
+
const shadowGsapOp: ShadowGsapOp = { kind: "remove", animationId };
|
|
45
56
|
commitMutationSafely(
|
|
46
57
|
selection,
|
|
47
58
|
{ type: "delete", animationId, stripStudioEdits: true },
|
|
48
|
-
{ label: "Delete GSAP animation" },
|
|
59
|
+
{ label: "Delete GSAP animation", shadowGsapOp },
|
|
49
60
|
);
|
|
61
|
+
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
50
62
|
},
|
|
51
|
-
[commitMutationSafely],
|
|
63
|
+
[commitMutationSafely, sdkSession],
|
|
52
64
|
);
|
|
53
65
|
|
|
54
66
|
const deleteAllForSelector = useCallback(
|
|
@@ -62,7 +74,10 @@ export function useGsapAnimationOps({
|
|
|
62
74
|
[commitMutation],
|
|
63
75
|
);
|
|
64
76
|
|
|
77
|
+
// Pre-existing complexity (auto-id assignment + per-method defaults); this PR
|
|
78
|
+
// adds only a guarded shadow-op construction at the tail.
|
|
65
79
|
const addGsapAnimation = useCallback(
|
|
80
|
+
// fallow-ignore-next-line complexity
|
|
66
81
|
async (
|
|
67
82
|
selection: DomEditSelection,
|
|
68
83
|
method: "to" | "from" | "set" | "fromTo",
|
|
@@ -95,6 +110,26 @@ export function useGsapAnimationOps({
|
|
|
95
110
|
fromTo: { x: 0, y: 0, opacity: 1 },
|
|
96
111
|
};
|
|
97
112
|
|
|
113
|
+
// Shadow op (server stays authoritative). "set" has no SDK method, so it
|
|
114
|
+
// is not shadowed; otherwise: existence via runShadowGsapTween (live) +
|
|
115
|
+
// value fidelity via the chokepoint (shadowGsapOp in options).
|
|
116
|
+
const shadowGsapOp: ShadowGsapOp | undefined =
|
|
117
|
+
selection.hfId && method !== "set"
|
|
118
|
+
? {
|
|
119
|
+
kind: "add",
|
|
120
|
+
target: selection.hfId,
|
|
121
|
+
tween: {
|
|
122
|
+
method,
|
|
123
|
+
position,
|
|
124
|
+
duration,
|
|
125
|
+
ease: "power2.out",
|
|
126
|
+
...(method === "fromTo"
|
|
127
|
+
? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
|
|
128
|
+
: { properties: toDefaults[method] ?? { opacity: 1 } }),
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
: undefined;
|
|
132
|
+
|
|
98
133
|
await commitMutation(
|
|
99
134
|
selection,
|
|
100
135
|
{
|
|
@@ -107,10 +142,12 @@ export function useGsapAnimationOps({
|
|
|
107
142
|
properties: toDefaults[method] ?? { opacity: 1 },
|
|
108
143
|
fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
|
|
109
144
|
},
|
|
110
|
-
{ label: `Add GSAP ${method} animation
|
|
145
|
+
{ label: `Add GSAP ${method} animation`, shadowGsapOp },
|
|
111
146
|
);
|
|
147
|
+
|
|
148
|
+
if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
112
149
|
},
|
|
113
|
-
[activeCompPath, commitMutation, projectIdRef, showToast],
|
|
150
|
+
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession],
|
|
114
151
|
);
|
|
115
152
|
|
|
116
153
|
return {
|
|
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
|
|
2
2
|
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import { applySoftReload } from "../utils/gsapSoftReload";
|
|
5
|
+
import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
|
|
5
6
|
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
|
|
6
7
|
import {
|
|
7
8
|
GsapMutationHttpError,
|
|
@@ -43,7 +44,10 @@ async function mutateGsapScript(
|
|
|
43
44
|
|
|
44
45
|
// oxfmt-ignore
|
|
45
46
|
// fallow-ignore-next-line complexity
|
|
46
|
-
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast }: GsapScriptCommitsParams) {
|
|
47
|
+
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
|
|
48
|
+
// Pre-existing complexity (server mutate + history + reload branches); this PR
|
|
49
|
+
// adds only a guarded shadow-fidelity dispatch.
|
|
50
|
+
// fallow-ignore-next-line complexity
|
|
47
51
|
const commitMutation = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
48
52
|
const pid = projectIdRef.current;
|
|
49
53
|
if (!pid) return;
|
|
@@ -64,6 +68,21 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
64
68
|
}
|
|
65
69
|
if (result.changed === false) return;
|
|
66
70
|
domEditSaveTimestampRef.current = Date.now();
|
|
71
|
+
// Shadow value fidelity: diff the SDK's GSAP writer output against the
|
|
72
|
+
// server's, from the same pre-op file. Fire-and-forget; server authoritative.
|
|
73
|
+
// Only meta-level ops carry shadowGsapOp today (add / update-meta / delete via
|
|
74
|
+
// useGsapAnimationOps). Per-property and keyframe handlers (useGsapPropertyDebounce,
|
|
75
|
+
// useGsapKeyframeOps) intentionally don't synthesize one yet — deferred follow-up.
|
|
76
|
+
// scriptText is null when the composition has no GSAP script; nothing to diff.
|
|
77
|
+
const fidelityArgs = resolveGsapFidelityArgs(
|
|
78
|
+
sdkSession,
|
|
79
|
+
options.shadowGsapOp,
|
|
80
|
+
result.before,
|
|
81
|
+
result.scriptText,
|
|
82
|
+
);
|
|
83
|
+
if (fidelityArgs) {
|
|
84
|
+
void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
|
|
85
|
+
}
|
|
67
86
|
if (result.before != null && result.after != null) {
|
|
68
87
|
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
|
|
69
88
|
}
|
|
@@ -77,11 +96,11 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
77
96
|
reloadPreview();
|
|
78
97
|
}
|
|
79
98
|
onCacheInvalidate();
|
|
80
|
-
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast]);
|
|
99
|
+
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
|
|
81
100
|
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
|
82
101
|
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
|
|
83
102
|
const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
|
|
84
|
-
const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast });
|
|
103
|
+
const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast, sdkSession });
|
|
85
104
|
const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure });
|
|
86
105
|
const arcPathOps = useGsapArcPathOps(commitMutationSafely);
|
|
87
106
|
return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps };
|
|
@@ -3,6 +3,10 @@ import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/
|
|
|
3
3
|
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
|
4
4
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
5
5
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
|
6
|
+
import {
|
|
7
|
+
clearKeyframeCacheForElement,
|
|
8
|
+
clearKeyframeCacheForFile,
|
|
9
|
+
} from "./gsapKeyframeCacheHelpers";
|
|
6
10
|
import { PROPERTY_DEFAULTS, toAbsoluteTime } from "./gsapShared";
|
|
7
11
|
|
|
8
12
|
function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
|
|
@@ -301,10 +305,7 @@ export function useGsapAnimationsForElement(
|
|
|
301
305
|
if (kf.easeEach) easeEach = kf.easeEach;
|
|
302
306
|
}
|
|
303
307
|
if (allKeyframes.length === 0) {
|
|
304
|
-
|
|
305
|
-
if (keyframeCache.has(`${sourceFile}#${elementId}`)) {
|
|
306
|
-
setKeyframeCache(`${sourceFile}#${elementId}`, undefined);
|
|
307
|
-
}
|
|
308
|
+
clearKeyframeCacheForElement(sourceFile, elementId);
|
|
308
309
|
return;
|
|
309
310
|
}
|
|
310
311
|
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
|
|
@@ -358,14 +359,11 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
358
359
|
const sf = sourceFile;
|
|
359
360
|
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
|
360
361
|
if (!parsed) return;
|
|
361
|
-
const { setKeyframeCache
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
setKeyframeCache(key, undefined);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
362
|
+
const { setKeyframeCache } = usePlayerStore.getState();
|
|
363
|
+
// Drop the file's stale entries (including the bare keys consumers read)
|
|
364
|
+
// before repopulating, so an element whose keyframes were removed and is
|
|
365
|
+
// absent from this scan doesn't keep showing diamonds.
|
|
366
|
+
clearKeyframeCacheForFile(sf);
|
|
369
367
|
const { elements } = usePlayerStore.getState();
|
|
370
368
|
const mergedByElement = new Map<string, GsapKeyframesData>();
|
|
371
369
|
for (const anim of parsed.animations) {
|
|
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../player";
|
|
|
3
3
|
import { usePlayerStore } from "../player";
|
|
4
4
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
5
5
|
import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
|
|
6
|
+
import { trackStudioRazorSplit } from "../telemetry/events";
|
|
6
7
|
import {
|
|
7
8
|
canSplitElement,
|
|
8
9
|
buildPatchTarget,
|
|
@@ -196,6 +197,7 @@ export function useRazorSplit({
|
|
|
196
197
|
});
|
|
197
198
|
|
|
198
199
|
reloadPreview();
|
|
200
|
+
trackStudioRazorSplit({ mode: "single", count: 1 });
|
|
199
201
|
showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
|
|
200
202
|
if (skippedSelectors?.length) {
|
|
201
203
|
showToast(
|
|
@@ -277,6 +279,7 @@ export function useRazorSplit({
|
|
|
277
279
|
});
|
|
278
280
|
|
|
279
281
|
reloadPreview();
|
|
282
|
+
trackStudioRazorSplit({ mode: "all", count: splitCount });
|
|
280
283
|
showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
|
|
281
284
|
} catch (error) {
|
|
282
285
|
const message = error instanceof Error ? error.message : "Failed to split clips";
|
|
@@ -1,20 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
3
|
import { getStudioSaveErrorMessage, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
4
|
-
|
|
5
|
-
type CommitMutationOptions = {
|
|
6
|
-
label: string;
|
|
7
|
-
coalesceKey?: string;
|
|
8
|
-
softReload?: boolean;
|
|
9
|
-
skipReload?: boolean;
|
|
10
|
-
beforeReload?: () => void;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
type CommitMutation = (
|
|
14
|
-
selection: DomEditSelection,
|
|
15
|
-
mutation: Record<string, unknown>,
|
|
16
|
-
options: CommitMutationOptions,
|
|
17
|
-
) => Promise<void>;
|
|
4
|
+
import type { CommitMutation, CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
18
5
|
|
|
19
6
|
type TrackGsapSaveFailure = (
|
|
20
7
|
error: unknown,
|
|
@@ -20,12 +20,15 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
|
|
|
20
20
|
* (projectId, activeCompPath) change, disposes the old one on cleanup, and
|
|
21
21
|
* re-opens it when the active composition file changes on disk (code editor,
|
|
22
22
|
* agent, or server-side patch) so the in-memory linkedom document never goes
|
|
23
|
-
* stale.
|
|
24
|
-
* "composition.html" default).
|
|
23
|
+
* stale.
|
|
25
24
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* Opened WITHOUT a persist queue: this session is shadow-telemetry +
|
|
26
|
+
* selection-sync only — it reads from the server but must NEVER write back.
|
|
27
|
+
* Shadow dispatch ops mutate the in-memory model and are discarded on the next
|
|
28
|
+
* reload-on-change (the studio's own authoritative write triggers it). Routing
|
|
29
|
+
* authoritative writes through this session (cutover, Step 3c+) must re-add
|
|
30
|
+
* persist TOGETHER WITH self-write suppression — without it, the SDK's
|
|
31
|
+
* serialize() output races and clobbers the studio's authoritative write.
|
|
29
32
|
*/
|
|
30
33
|
export function useSdkSession(
|
|
31
34
|
projectId: string | null,
|
|
@@ -37,6 +40,9 @@ export function useSdkSession(
|
|
|
37
40
|
// ── Re-open on external change to the active composition ──
|
|
38
41
|
useEffect(() => {
|
|
39
42
|
if (!activeCompPath) return;
|
|
43
|
+
// Pre-existing clone of the file-change reload handler (usePreviewPersistence);
|
|
44
|
+
// surfaced by this PR's adjacent edits, not introduced by it.
|
|
45
|
+
// fallow-ignore-next-line code-duplication
|
|
40
46
|
const handler = (payload?: unknown) => {
|
|
41
47
|
if (shouldReloadSdkSession(payload, activeCompPath)) {
|
|
42
48
|
setReloadToken((t) => t + 1);
|
|
@@ -69,13 +75,10 @@ export function useSdkSession(
|
|
|
69
75
|
.read(activeCompPath)
|
|
70
76
|
.then(async (content) => {
|
|
71
77
|
if (cancelled || typeof content !== "string") return;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
comp.on("persist:error", (e) => {
|
|
77
|
-
console.warn("[sdk] persist:error", e.error);
|
|
78
|
-
});
|
|
78
|
+
// No persist — shadow/selection only; see the hook docstring. The SDK
|
|
79
|
+
// must not write back to the server while it shadows the authoritative
|
|
80
|
+
// studio path.
|
|
81
|
+
comp = await openComposition(content);
|
|
79
82
|
// Cleanup may have fired while openComposition was awaited; dispose immediately.
|
|
80
83
|
if (cancelled) {
|
|
81
84
|
comp.dispose();
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
// Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale +
|
|
2
|
+
// playback-start resolution); this PR adds guarded shadow-timing dispatches in
|
|
3
|
+
// the move/resize .then() chains, which nudges several callbacks over the CC
|
|
4
|
+
// threshold. The added branches are telemetry-only.
|
|
5
|
+
// fallow-ignore-file complexity
|
|
1
6
|
import { useCallback, useRef } from "react";
|
|
7
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
8
|
+
import { runShadowTiming } from "../utils/sdkShadow";
|
|
2
9
|
import type { TimelineElement } from "../player";
|
|
3
10
|
import { usePlayerStore } from "../player";
|
|
4
11
|
import { useRazorSplit } from "./useRazorSplit";
|
|
@@ -33,7 +40,7 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
|
33
40
|
|
|
34
41
|
// ── Types ──
|
|
35
42
|
|
|
36
|
-
|
|
43
|
+
interface RecordEditInput {
|
|
37
44
|
label: string;
|
|
38
45
|
kind: EditHistoryKind;
|
|
39
46
|
coalesceKey?: string;
|
|
@@ -53,6 +60,8 @@ interface UseTimelineEditingOptions {
|
|
|
53
60
|
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
54
61
|
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
55
62
|
isRecordingRef?: React.RefObject<boolean>;
|
|
63
|
+
/** Stage 7 Step 3b: SDK session for shadow timing dispatch (server stays authoritative). */
|
|
64
|
+
sdkSession?: Composition | null;
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
// ── Hook ──
|
|
@@ -70,6 +79,7 @@ export function useTimelineEditing({
|
|
|
70
79
|
pendingTimelineEditPathRef,
|
|
71
80
|
uploadProjectFiles,
|
|
72
81
|
isRecordingRef,
|
|
82
|
+
sdkSession,
|
|
73
83
|
}: UseTimelineEditingOptions) {
|
|
74
84
|
const projectIdRef = useRef(projectId);
|
|
75
85
|
projectIdRef.current = projectId;
|
|
@@ -138,6 +148,11 @@ export function useTimelineEditing({
|
|
|
138
148
|
value: String(updates.track),
|
|
139
149
|
});
|
|
140
150
|
}).then(() => {
|
|
151
|
+
if (sdkSession)
|
|
152
|
+
runShadowTiming(sdkSession, element.hfId, {
|
|
153
|
+
start: updates.start,
|
|
154
|
+
trackIndex: updates.track,
|
|
155
|
+
});
|
|
141
156
|
const pid = projectIdRef.current;
|
|
142
157
|
if (delta !== 0 && element.domId && pid) {
|
|
143
158
|
return shiftGsapPositions(pid, filePath, element.domId, delta)
|
|
@@ -146,7 +161,7 @@ export function useTimelineEditing({
|
|
|
146
161
|
}
|
|
147
162
|
});
|
|
148
163
|
},
|
|
149
|
-
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
|
|
164
|
+
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession],
|
|
150
165
|
);
|
|
151
166
|
|
|
152
167
|
const handleTimelineElementResize = useCallback(
|
|
@@ -190,6 +205,11 @@ export function useTimelineEditing({
|
|
|
190
205
|
}
|
|
191
206
|
return patched;
|
|
192
207
|
}).then(() => {
|
|
208
|
+
if (sdkSession)
|
|
209
|
+
runShadowTiming(sdkSession, element.hfId, {
|
|
210
|
+
start: updates.start,
|
|
211
|
+
duration: updates.duration,
|
|
212
|
+
});
|
|
193
213
|
const pid = projectIdRef.current;
|
|
194
214
|
if (timingChanged && element.domId && pid) {
|
|
195
215
|
return scaleGsapPositions(
|
|
@@ -207,7 +227,7 @@ export function useTimelineEditing({
|
|
|
207
227
|
return reloadPreview();
|
|
208
228
|
});
|
|
209
229
|
},
|
|
210
|
-
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
|
|
230
|
+
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession],
|
|
211
231
|
);
|
|
212
232
|
|
|
213
233
|
const handleTimelineElementDelete = useCallback(
|
|
@@ -3,6 +3,7 @@ import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
|
|
3
3
|
import { isMusicTrack } from "../../utils/timelineInspector";
|
|
4
4
|
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
|
|
5
5
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
6
|
+
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
|
|
6
7
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
7
8
|
import { EditPopover } from "./EditModal";
|
|
8
9
|
import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
|
|
@@ -28,7 +29,10 @@ import {
|
|
|
28
29
|
shouldShowTimelineShortcutHint,
|
|
29
30
|
} from "./timelineLayout";
|
|
30
31
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
useResolvedTimelineEditCallbacks,
|
|
34
|
+
type TimelineEditOverrides,
|
|
35
|
+
} from "./useResolvedTimelineEditCallbacks";
|
|
32
36
|
|
|
33
37
|
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
|
34
38
|
export {
|
|
@@ -45,7 +49,7 @@ export {
|
|
|
45
49
|
getDefaultDroppedTrack,
|
|
46
50
|
} from "./timelineLayout";
|
|
47
51
|
|
|
48
|
-
interface TimelineProps extends TimelineDropCallbacks {
|
|
52
|
+
interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
|
|
49
53
|
onSeek?: (time: number) => void;
|
|
50
54
|
onDrillDown?: (element: TimelineElement) => void;
|
|
51
55
|
renderClipContent?: (
|
|
@@ -67,6 +71,10 @@ export const Timeline = memo(function Timeline({
|
|
|
67
71
|
onAssetDrop,
|
|
68
72
|
onBlockDrop,
|
|
69
73
|
onDeleteElement: _onDeleteElement,
|
|
74
|
+
onMoveElement: onMoveElementOverride,
|
|
75
|
+
onResizeElement: onResizeElementOverride,
|
|
76
|
+
onBlockedEditAttempt: onBlockedEditAttemptOverride,
|
|
77
|
+
onSplitElement: onSplitElementOverride,
|
|
70
78
|
onSelectElement,
|
|
71
79
|
theme: themeOverrides,
|
|
72
80
|
}: TimelineProps = {}) {
|
|
@@ -80,14 +88,18 @@ export const Timeline = memo(function Timeline({
|
|
|
80
88
|
onDeleteAllKeyframes,
|
|
81
89
|
onChangeKeyframeEase,
|
|
82
90
|
onMoveKeyframe,
|
|
83
|
-
} =
|
|
91
|
+
} = useResolvedTimelineEditCallbacks({
|
|
92
|
+
onMoveElement: onMoveElementOverride,
|
|
93
|
+
onResizeElement: onResizeElementOverride,
|
|
94
|
+
onBlockedEditAttempt: onBlockedEditAttemptOverride,
|
|
95
|
+
onSplitElement: onSplitElementOverride,
|
|
96
|
+
});
|
|
84
97
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
85
98
|
useMusicBeatAnalysis();
|
|
86
|
-
const
|
|
99
|
+
const rawElements = usePlayerStore((s) => s.elements);
|
|
100
|
+
const expandedElements = useExpandedTimelineElements();
|
|
87
101
|
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
|
|
88
102
|
const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null);
|
|
89
|
-
|
|
90
|
-
// Merge user edits + remap beats from audio-file → composition coordinates.
|
|
91
103
|
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
|
92
104
|
const adjustedBeatAnalysis = useMemo(
|
|
93
105
|
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
|
|
@@ -176,21 +188,21 @@ export const Timeline = memo(function Timeline({
|
|
|
176
188
|
|
|
177
189
|
const effectiveDuration = useMemo(() => {
|
|
178
190
|
const safeDur = Number.isFinite(duration) ? duration : 0;
|
|
179
|
-
if (
|
|
180
|
-
const maxEnd = Math.max(...
|
|
191
|
+
if (rawElements.length === 0) return safeDur;
|
|
192
|
+
const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration));
|
|
181
193
|
const result = Math.max(safeDur, maxEnd);
|
|
182
194
|
return Number.isFinite(result) ? result : safeDur;
|
|
183
|
-
}, [
|
|
195
|
+
}, [rawElements, duration]);
|
|
184
196
|
|
|
185
197
|
const tracks = useMemo(() => {
|
|
186
|
-
const map = new Map<number, typeof
|
|
187
|
-
for (const el of
|
|
198
|
+
const map = new Map<number, typeof expandedElements>();
|
|
199
|
+
for (const el of expandedElements) {
|
|
188
200
|
const list = map.get(el.track) ?? [];
|
|
189
201
|
list.push(el);
|
|
190
202
|
map.set(el.track, list);
|
|
191
203
|
}
|
|
192
204
|
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
|
193
|
-
}, [
|
|
205
|
+
}, [expandedElements]);
|
|
194
206
|
|
|
195
207
|
const trackStyles = useMemo(() => {
|
|
196
208
|
const map = new Map<number, TrackVisualStyle>();
|
|
@@ -247,8 +259,9 @@ export const Timeline = memo(function Timeline({
|
|
|
247
259
|
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
|
|
248
260
|
|
|
249
261
|
const selectedElement = useMemo(
|
|
250
|
-
() =>
|
|
251
|
-
|
|
262
|
+
() =>
|
|
263
|
+
expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
264
|
+
[expandedElements, selectedElementId],
|
|
252
265
|
);
|
|
253
266
|
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
|
|
254
267
|
selectedElementRef.current = selectedElement;
|
|
@@ -283,7 +296,7 @@ export const Timeline = memo(function Timeline({
|
|
|
283
296
|
effectiveDuration,
|
|
284
297
|
pps,
|
|
285
298
|
timelineReady,
|
|
286
|
-
elementsLength:
|
|
299
|
+
elementsLength: expandedElements.length,
|
|
287
300
|
setZoomMode,
|
|
288
301
|
setManualZoomPercent,
|
|
289
302
|
onSeek,
|
|
@@ -332,7 +345,7 @@ export const Timeline = memo(function Timeline({
|
|
|
332
345
|
|
|
333
346
|
useEffect(() => {
|
|
334
347
|
syncShortcutHintVisibility();
|
|
335
|
-
}, [syncShortcutHintVisibility, timelineReady,
|
|
348
|
+
}, [syncShortcutHintVisibility, timelineReady, expandedElements.length, totalH]);
|
|
336
349
|
|
|
337
350
|
const getPreviewElement = useCallback(
|
|
338
351
|
(element: TimelineElement): TimelineElement => {
|
|
@@ -362,7 +375,7 @@ export const Timeline = memo(function Timeline({
|
|
|
362
375
|
onBlockDrop,
|
|
363
376
|
});
|
|
364
377
|
|
|
365
|
-
if (!timelineReady ||
|
|
378
|
+
if (!timelineReady || expandedElements.length === 0) {
|
|
366
379
|
return (
|
|
367
380
|
<TimelineEmptyState
|
|
368
381
|
isDragOver={isDragOver}
|
|
@@ -482,7 +495,7 @@ export const Timeline = memo(function Timeline({
|
|
|
482
495
|
}
|
|
483
496
|
}}
|
|
484
497
|
onContextMenuKeyframe={(e, elId, pct) => {
|
|
485
|
-
const el =
|
|
498
|
+
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
|
|
486
499
|
if (el) {
|
|
487
500
|
setSelectedElementId(elId);
|
|
488
501
|
onSelectElement?.(el);
|