@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.
Files changed (44) hide show
  1. package/dist/assets/index-BITwbxi-.css +1 -0
  2. package/dist/assets/{index-CKWBqyRd.js → index-BZKngETE.js} +1 -1
  3. package/dist/assets/index-BzjItfjX.js +296 -0
  4. package/dist/assets/{index-gpSohHUn.js → index-C0vMHtMH.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +5 -5
  7. package/src/App.tsx +2 -1
  8. package/src/components/editor/PropertyPanel.tsx +24 -16
  9. package/src/components/editor/manualEditingAvailability.ts +5 -3
  10. package/src/components/nle/NLELayout.tsx +89 -1
  11. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +121 -0
  12. package/src/hooks/gsapKeyframeCacheHelpers.ts +48 -2
  13. package/src/hooks/gsapScriptCommitHelpers.ts +8 -5
  14. package/src/hooks/gsapScriptCommitTypes.ts +6 -0
  15. package/src/hooks/gsapTargetCache.ts +65 -0
  16. package/src/hooks/useAppHotkeys.ts +10 -0
  17. package/src/hooks/useDomEditCommits.ts +6 -5
  18. package/src/hooks/useDomEditSession.ts +6 -1
  19. package/src/hooks/useDomGeometryCommits.ts +1 -36
  20. package/src/hooks/useElementLifecycleOps.ts +5 -0
  21. package/src/hooks/useGsapAnimationOps.ts +46 -9
  22. package/src/hooks/useGsapScriptCommits.ts +22 -3
  23. package/src/hooks/useGsapTweenCache.ts +10 -12
  24. package/src/hooks/useRazorSplit.ts +3 -0
  25. package/src/hooks/useSafeGsapCommitMutation.ts +1 -14
  26. package/src/hooks/useSdkSession.ts +15 -12
  27. package/src/hooks/useTimelineEditing.ts +23 -3
  28. package/src/player/components/Timeline.tsx +31 -18
  29. package/src/player/components/TimelineClip.tsx +3 -3
  30. package/src/player/components/useResolvedTimelineEditCallbacks.ts +30 -0
  31. package/src/player/hooks/useExpandedTimelineElements.test.ts +91 -0
  32. package/src/player/hooks/useExpandedTimelineElements.ts +153 -0
  33. package/src/player/hooks/useTimelineSyncCallbacks.ts +22 -0
  34. package/src/player/store/playerStore.ts +22 -8
  35. package/src/telemetry/events.test.ts +16 -1
  36. package/src/telemetry/events.ts +15 -0
  37. package/src/utils/blockCategories.ts +2 -2
  38. package/src/utils/sdkShadow.test.ts +232 -2
  39. package/src/utils/sdkShadow.ts +230 -2
  40. package/src/utils/sdkShadowGsapFidelity.ts +208 -0
  41. package/src/utils/studioHelpers.test.ts +25 -1
  42. package/src/utils/studioHelpers.ts +54 -28
  43. package/dist/assets/index-B62bDCQv.css +0 -1
  44. 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
- const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
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, keyframeCache } = usePlayerStore.getState();
362
- const sfPrefix = `${sf}#`;
363
- const fallbackPrefix = "index.html#";
364
- for (const key of keyframeCache.keys()) {
365
- if (key.startsWith(sfPrefix) || (sf !== "index.html" && key.startsWith(fallbackPrefix))) {
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. The persist queue writes back to `activeCompPath` (not the
24
- * "composition.html" default).
23
+ * stale.
25
24
  *
26
- * The session is idle until Step 3c routes dispatch ops through it; re-opening
27
- * is therefore purely additive no SDK self-write exists yet, so there is no
28
- * persist echo. Step 3c must add self-write suppression once dispatch writes.
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
- comp = await openComposition(content, {
73
- persist: adapter,
74
- persistPath: activeCompPath,
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
- export interface RecordEditInput {
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 { useTimelineEditContext } from "../../contexts/TimelineEditContext";
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
- } = useTimelineEditContext();
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 elements = usePlayerStore((s) => s.elements);
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 (elements.length === 0) return safeDur;
180
- const maxEnd = Math.max(...elements.map((el) => el.start + el.duration));
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
- }, [elements, duration]);
195
+ }, [rawElements, duration]);
184
196
 
185
197
  const tracks = useMemo(() => {
186
- const map = new Map<number, typeof elements>();
187
- for (const el of elements) {
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
- }, [elements]);
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
- () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
251
- [elements, selectedElementId],
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: elements.length,
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, elements.length, totalH]);
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 || elements.length === 0) {
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 = elements.find((x) => (x.key ?? x.id) === elId);
498
+ const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
486
499
  if (el) {
487
500
  setSelectedElementId(elId);
488
501
  onSelectElement?.(el);