@hyperframes/studio 0.6.122-alpha.0 → 0.7.0

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 (98) hide show
  1. package/dist/assets/index-CbNg8Ghm.js +374 -0
  2. package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
  3. package/dist/assets/{index-i-MLqhmm.js → index-DsxCdV8b.js} +1 -1
  4. package/dist/assets/index-DzWIinxk.css +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3757 -2232
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -39
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditSession.ts +0 -4
  61. package/src/hooks/useDomSelection.ts +12 -5
  62. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  63. package/src/hooks/useEnableKeyframes.ts +298 -45
  64. package/src/hooks/useGestureCommit.ts +121 -53
  65. package/src/hooks/useGestureRecording.ts +61 -42
  66. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  67. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  68. package/src/hooks/useGsapAnimationOps.ts +1 -1
  69. package/src/hooks/useGsapAwareEditing.ts +38 -40
  70. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  71. package/src/hooks/useGsapScriptCommits.ts +102 -6
  72. package/src/hooks/useGsapTweenCache.ts +7 -1
  73. package/src/hooks/useRazorSplit.ts +21 -2
  74. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  75. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  76. package/src/hooks/useServerConnection.ts +4 -1
  77. package/src/hooks/useStudioContextValue.ts +3 -2
  78. package/src/hooks/useStudioUrlState.ts +97 -37
  79. package/src/player/components/ShortcutsPanel.tsx +8 -0
  80. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  81. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  82. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  83. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  84. package/src/player/lib/mediaProbe.ts +7 -1
  85. package/src/player/store/playerStore.ts +38 -1
  86. package/src/telemetry/config.ts +3 -2
  87. package/src/utils/editDebugLog.ts +16 -0
  88. package/src/utils/generateId.ts +7 -0
  89. package/src/utils/gsapSoftReload.test.ts +217 -13
  90. package/src/utils/gsapSoftReload.ts +228 -50
  91. package/src/utils/studioHelpers.test.ts +31 -1
  92. package/src/utils/studioHelpers.ts +24 -0
  93. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  94. package/src/utils/studioPreviewHelpers.ts +30 -6
  95. package/src/utils/studioTelemetry.ts +3 -1
  96. package/dist/assets/index-Baz2T-9U.js +0 -269
  97. package/dist/assets/index-BvEhf7SS.css +0 -1
  98. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -42,12 +42,16 @@ export function useSafeGsapCommitMutation(
42
42
  selection: DomEditSelection,
43
43
  mutation: Record<string, unknown>,
44
44
  options: CommitMutationOptions,
45
- ) => {
46
- void commitMutation(selection, mutation, options).catch((error) => {
45
+ ): Promise<void> =>
46
+ // Return the chain so awaiting consumers (gesture commit, enable-keyframes)
47
+ // run their post-actions AFTER the server save settles, not immediately.
48
+ // The `.catch` handles the failure (toast + telemetry) and resolves the
49
+ // chain, so awaiters see a settled (success-after-handled) promise rather
50
+ // than an unhandled rejection.
51
+ commitMutation(selection, mutation, options).catch((error) => {
47
52
  trackGsapSaveFailure(error, selection, mutation, options.label);
48
53
  showToast?.(`Couldn't save animation: ${getStudioSaveErrorMessage(error)}`, "error");
49
- });
50
- },
54
+ }),
51
55
  [commitMutation, trackGsapSaveFailure, showToast],
52
56
  );
53
57
  }
@@ -26,7 +26,10 @@ export function useServerConnection(): ServerConnectionState {
26
26
  useMountEffect(() => {
27
27
  const hashProjectId = parseProjectIdFromHash(window.location.hash);
28
28
  let cancelled = false;
29
- let retryTimer: ReturnType<typeof window.setTimeout> | null = null;
29
+ // Explicitly `number` (the DOM return of window.setTimeout) rather than
30
+ // ReturnType<typeof window.setTimeout> — with @types/node present, that infers
31
+ // NodeJS.Timeout and clashes with the DOM number the call actually returns.
32
+ let retryTimer: number | null = null;
30
33
 
31
34
  function scheduleRetry() {
32
35
  setWaitingForServer(true);
@@ -90,8 +90,9 @@ export function useInspectorState(
90
90
  inspectorPanelActive,
91
91
  inspectorButtonActive:
92
92
  STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
93
- shouldShowSelectedDomBounds:
94
- inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording,
93
+ // Keep the selection box + motion path drawn even when the Inspector is
94
+ // collapsed closing the panel shouldn't visually deselect the element.
95
+ shouldShowSelectedDomBounds: inspectorPanelActive && !isPlaying && !isGestureRecording,
95
96
  };
96
97
  }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
97
98
  }
@@ -1,9 +1,11 @@
1
- import { useCallback, useEffect, useRef } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { usePlayerStore } from "../player";
3
3
  import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
4
4
  import { clampNumber, type RightPanelTab } from "../utils/studioHelpers";
5
+ import { parseProjectIdFromHash } from "../utils/projectRouting";
5
6
  import {
6
7
  buildStudioHash,
8
+ parseStudioUrlStateFromHash,
7
9
  type StudioUrlSelectionState,
8
10
  type StudioUrlState,
9
11
  } from "../utils/studioUrlState";
@@ -33,6 +35,7 @@ interface UseStudioUrlStateParams {
33
35
  preserveGroup?: boolean;
34
36
  },
35
37
  ) => void;
38
+ setRightPanelTab: (tab: RightPanelTab) => void;
36
39
  initialState: StudioUrlState;
37
40
  }
38
41
 
@@ -68,12 +71,17 @@ export function useStudioUrlState({
68
71
  domEditSelection,
69
72
  buildDomSelectionFromTarget,
70
73
  applyDomSelection,
74
+ setRightPanelTab,
71
75
  initialState,
72
76
  }: UseStudioUrlStateParams) {
73
77
  const currentTime = usePlayerStore((s) => s.currentTime);
74
78
  const hydratedSeekRef = useRef(initialState.currentTime == null);
75
79
  const hydratedInitialTimeRef = useRef(initialState.currentTime == null);
76
80
  const hydratedSelectionRef = useRef(initialState.selection == null);
81
+ // Mirrors hydratedSelectionRef as state so the selection-hydration effect can
82
+ // drop its currentTime subscription once hydration completes — otherwise it
83
+ // re-runs on every playhead tick for the lifetime of the session.
84
+ const [selectionHydrated, setSelectionHydrated] = useState(initialState.selection == null);
77
85
  const pendingSelectionRef = useRef(initialState.selection);
78
86
  const stableTimeRef = useRef<number | null>(initialState.currentTime);
79
87
 
@@ -91,67 +99,91 @@ export function useStudioUrlState({
91
99
  [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible],
92
100
  );
93
101
 
102
+ // Resolve a URL selection to a live element and apply it. Shared by the initial
103
+ // hydration effect and the external-navigation (hashchange) handler. Returns
104
+ // false ONLY when the iframe document isn't ready yet (caller should retry);
105
+ // a missing element or null selection clears the selection and returns true.
106
+ const applyUrlSelection = useCallback(
107
+ (selection: StudioUrlSelectionState | null): boolean => {
108
+ if (!selection) {
109
+ applyDomSelection(null, { revealPanel: false });
110
+ return true;
111
+ }
112
+ let doc: Document | null = null;
113
+ try {
114
+ doc = previewIframeRef.current?.contentDocument ?? null;
115
+ } catch {
116
+ return false;
117
+ }
118
+ if (!doc) return false;
119
+ const element = findElementForSelection(
120
+ doc,
121
+ {
122
+ sourceFile: selection.sourceFile ?? "",
123
+ id: selection.id,
124
+ selector: selection.selector,
125
+ selectorIndex: selection.selectorIndex,
126
+ },
127
+ activeCompPath,
128
+ );
129
+ if (!element) {
130
+ applyDomSelection(null, { revealPanel: false });
131
+ return true;
132
+ }
133
+ void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((resolved) => {
134
+ applyDomSelection(resolved, { revealPanel: false });
135
+ });
136
+ return true;
137
+ },
138
+ [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
139
+ );
140
+
94
141
  useEffect(() => {
95
142
  if (!projectId || hydratedSeekRef.current || compositionLoading) return;
96
143
  const nextTime =
97
144
  duration > 0
98
145
  ? clampNumber(initialState.currentTime ?? 0, 0, duration)
99
146
  : Math.max(0, initialState.currentTime ?? 0);
147
+ // The request is honored even if it fires before the player runtime mounts:
148
+ // initializeAdapter reconciles the store's requestedSeekTime when the adapter
149
+ // becomes ready. currentTime then settles to nextTime, releasing the selection
150
+ // hydration below.
100
151
  usePlayerStore.getState().requestSeek(nextTime);
101
152
  stableTimeRef.current = nextTime;
102
153
  hydratedSeekRef.current = true;
103
154
  }, [projectId, compositionLoading, duration, initialState.currentTime]);
104
155
 
156
+ // Once hydration completes the selection effect no longer needs the playhead,
157
+ // so freeze its time dependency. This stops the effect re-running on every tick
158
+ // for the rest of the session (cosmetic perf) while still retrying as the seek
159
+ // settles before hydration.
160
+ const selectionHydrationTime = selectionHydrated ? 0 : currentTime;
105
161
  useEffect(() => {
106
162
  if (!projectId || hydratedSelectionRef.current || compositionLoading) return;
107
163
  if (!hydratedSeekRef.current) return;
108
164
  const targetTime = initialState.currentTime;
109
- if (targetTime != null && Math.abs(currentTime - stableTimeRef.current!) > 0.05) return;
110
-
111
- const pendingSelection = pendingSelectionRef.current;
112
- if (!pendingSelection) {
113
- hydratedSelectionRef.current = true;
165
+ if (targetTime != null && Math.abs(selectionHydrationTime - stableTimeRef.current!) > 0.05) {
114
166
  return;
115
167
  }
116
168
 
117
- let doc: Document | null = null;
118
- try {
119
- doc = previewIframeRef.current?.contentDocument ?? null;
120
- } catch {
121
- return;
122
- }
123
- if (!doc) return;
124
-
125
- const element = findElementForSelection(
126
- doc,
127
- {
128
- sourceFile: pendingSelection.sourceFile ?? "",
129
- id: pendingSelection.id,
130
- selector: pendingSelection.selector,
131
- selectorIndex: pendingSelection.selectorIndex,
132
- },
133
- activeCompPath,
134
- );
135
- if (!element) {
136
- applyDomSelection(null, { revealPanel: false });
169
+ const markHydrated = () => {
137
170
  hydratedSelectionRef.current = true;
138
- pendingSelectionRef.current = null;
171
+ setSelectionHydrated(true);
172
+ };
173
+ const pendingSelection = pendingSelectionRef.current;
174
+ if (!pendingSelection) {
175
+ markHydrated();
139
176
  return;
140
177
  }
141
-
142
- hydratedSelectionRef.current = true;
178
+ // Doc not ready yet → leave hydration pending so a later tick retries.
179
+ if (!applyUrlSelection(pendingSelection)) return;
180
+ markHydrated();
143
181
  pendingSelectionRef.current = null;
144
- void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((selection) => {
145
- applyDomSelection(selection, { revealPanel: false });
146
- });
147
182
  }, [
148
- activeCompPath,
149
- applyDomSelection,
150
- buildDomSelectionFromTarget,
183
+ applyUrlSelection,
151
184
  compositionLoading,
152
- currentTime,
185
+ selectionHydrationTime,
153
186
  initialState.currentTime,
154
- previewIframeRef,
155
187
  projectId,
156
188
  refreshKey,
157
189
  ]);
@@ -185,4 +217,32 @@ export function useStudioUrlState({
185
217
  if (!projectId) return;
186
218
  replaceHash(buildStudioHash(projectId, buildUrlState()));
187
219
  }, [activeCompPathHydrated, buildUrlState, projectId]);
220
+
221
+ // Re-apply URL state when the hash changes externally (pasting a new link,
222
+ // back/forward) AFTER initial load. The app only reads the URL once on mount
223
+ // and otherwise WRITES the hash via replaceState (which never fires
224
+ // `hashchange`), so this listener sees only genuine external navigations —
225
+ // without it, opening a same-project deep link (different `t`, element, or
226
+ // tab) is silently ignored and then overwritten by the next hash-sync.
227
+ useEffect(() => {
228
+ if (!projectId) return;
229
+ const onHashChange = () => {
230
+ if (parseProjectIdFromHash(window.location.hash) !== projectId) return; // different project → remount handles it
231
+ const parsed = parseStudioUrlStateFromHash(window.location.hash);
232
+ if (parsed.currentTime != null) {
233
+ const clamped =
234
+ duration > 0
235
+ ? clampNumber(parsed.currentTime, 0, duration)
236
+ : Math.max(0, parsed.currentTime);
237
+ if (Math.abs(usePlayerStore.getState().currentTime - clamped) > 0.05) {
238
+ usePlayerStore.getState().requestSeek(clamped);
239
+ stableTimeRef.current = clamped;
240
+ }
241
+ }
242
+ applyUrlSelection(parsed.selection);
243
+ if (parsed.rightPanelTab) setRightPanelTab(parsed.rightPanelTab);
244
+ };
245
+ window.addEventListener("hashchange", onHashChange);
246
+ return () => window.removeEventListener("hashchange", onHashChange);
247
+ }, [projectId, duration, applyUrlSelection, setRightPanelTab]);
188
248
  }
@@ -50,6 +50,14 @@ const SHORTCUT_SECTIONS = [
50
50
  { key: "⌘ Scroll", label: "Record scale" },
51
51
  ],
52
52
  },
53
+ {
54
+ title: "Canvas",
55
+ hints: [
56
+ { key: "Drag", label: "Move element / add keyframe" },
57
+ { key: "⌥ Drag", label: "Move entire animation path" },
58
+ { key: "⇧ Drag", label: "Uniform resize" },
59
+ ],
60
+ },
53
61
  {
54
62
  title: "Panels",
55
63
  hints: [
@@ -42,13 +42,10 @@ interface TimelineClipDiamondsProps {
42
42
  const DIAMOND_RATIO = 0.8;
43
43
  // Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
44
44
  // slightly outside [0, 100] (from rounding or stale cache during the async
45
- // persist → reload cycle) are clamped to the clip edge rather than hidden.
46
- export const KF_MIN_PCT = -5;
47
- export const KF_MAX_PCT = 105;
48
-
49
- function clampDiamondLeft(rawLeft: number, diamondSize: number, clipWidth: number): number {
50
- return Math.max(0, Math.min(clipWidth - diamondSize, rawLeft));
51
- }
45
+ // persist → reload cycle) are still rendered (the clip is overflow-visible) at
46
+ // their true position rather than hidden.
47
+ const KF_MIN_PCT = -5;
48
+ const KF_MAX_PCT = 105;
52
49
 
53
50
  export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
54
51
  keyframesData,
@@ -225,11 +222,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
225
222
  })}
226
223
 
227
224
  {sorted.map((kf, i) => {
228
- const leftPx = clampDiamondLeft(
229
- (effPct(kf.percentage) / 100) * clipWidthPx - half,
230
- diamondSize,
231
- clipWidthPx,
232
- );
225
+ // Center the diamond ON its keyframe %: left = (% · width) − half so the
226
+ // diamond's midpoint sits exactly at the percentage. At 0% the midpoint
227
+ // is the clip's left edge (the diamond's left half overflows, which the
228
+ // overflow-visible clip shows) — NOT shifted fully inside. No clamp, or
229
+ // boundary keyframes (0% / 100%) would render off-center.
230
+ const leftPx = (effPct(kf.percentage) / 100) * clipWidthPx - half;
233
231
  const kfKey = `${elementId}:${kf.percentage}`;
234
232
  const isKfSelected = selectedKeyframes.has(kfKey);
235
233
  const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { buildExpandedElements } from "./useExpandedTimelineElements";
3
+ import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
3
4
  import type { TimelineElement } from "../store/playerStore";
4
5
  import type { ClipManifestClip } from "../lib/playbackTypes";
5
6
 
@@ -88,4 +89,37 @@ describe("buildExpandedElements", () => {
88
89
  expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10
89
90
  expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
90
91
  });
92
+
93
+ // Regression: an expanded child must share one identity (`key`) with the flat
94
+ // store element for the same DOM id. Before the fix the child key fell back to
95
+ // the colon form (`index.html:eyebrow:N`) while the store/selection used the
96
+ // hash form (`index.html#eyebrow`), so clicking an expanded child never
97
+ // highlighted it (isSelected compares the two keys).
98
+ it("keys expanded children in hash form, matching the flat store element", () => {
99
+ // Single composition (no sub-comps): scene `s1` with same-file children.
100
+ const elements = [el({ id: "s1", domId: "s1", start: 0, duration: 14 })];
101
+ const manifest = [
102
+ clip({ id: "s1", start: 0, duration: 14 }),
103
+ clip({ id: "eyebrow", start: 0, duration: 14 }),
104
+ clip({ id: "title", start: 0, duration: 14 }),
105
+ ];
106
+ const parentMap = new Map([
107
+ ["eyebrow", "s1"],
108
+ ["title", "s1"],
109
+ ]);
110
+
111
+ const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
112
+ const child = out.find((e) => e.domId === "eyebrow")!;
113
+
114
+ const expectedStoreKey = buildTimelineElementKey({
115
+ id: "eyebrow",
116
+ fallbackIndex: 0,
117
+ domId: "eyebrow",
118
+ selector: "#eyebrow",
119
+ sourceFile: undefined,
120
+ });
121
+ expect(expectedStoreKey).toBe("index.html#eyebrow");
122
+ expect(child.key).toBe("index.html#eyebrow");
123
+ expect(child.key).toBe(expectedStoreKey);
124
+ });
91
125
  });
@@ -2,6 +2,7 @@ import { useMemo } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
3
  import type { ClipManifestClip } from "../lib/playbackTypes";
4
4
  import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
5
+ import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
5
6
 
6
7
  function findTopLevelAncestor(id: string, parentMap: Map<string, string>): string | null {
7
8
  let current = parentMap.get(id);
@@ -78,14 +79,31 @@ function buildChildElements(
78
79
  clip: child,
79
80
  fallbackIndex: result.length,
80
81
  });
82
+ const domId = child.id ?? undefined;
83
+ const selector = child.id ? `#${child.id}` : undefined;
84
+ // `base.key` was built without a hostEl, so it fell back to the colon form
85
+ // (`index.html:<id>:<idx>`) even though we set domId below. Recompute it from
86
+ // the same inputs the store uses (`<sourceFile>#<domId>`) so an expanded
87
+ // child shares one identity with its flat store element — otherwise selecting
88
+ // it sets `selectedElementId` to the store's hash key while the rendered row
89
+ // is keyed by the colon form, and `isSelected` never matches (no highlight).
90
+ const key = buildTimelineElementKey({
91
+ id: base.id,
92
+ fallbackIndex: result.length,
93
+ domId,
94
+ selector,
95
+ selectorIndex: base.selectorIndex,
96
+ sourceFile: editBasis.sourceFile,
97
+ });
81
98
  result.push({
82
99
  ...base,
100
+ key,
83
101
  start: clamped.start,
84
102
  duration: clamped.duration,
85
103
  track: display.track + result.length,
86
104
  expandedParentStart: editBasis.start,
87
- domId: child.id ?? undefined,
88
- selector: child.id ? `#${child.id}` : undefined,
105
+ domId,
106
+ selector,
89
107
  sourceFile: editBasis.sourceFile,
90
108
  timingSource: "authored" as const,
91
109
  });
@@ -174,8 +174,16 @@ export function useTimelineSyncCallbacks({
174
174
  if (!adapter || adapter.getDuration() <= 0) return false;
175
175
 
176
176
  adapter.pause();
177
- const seekTo = pendingSeekRef.current;
177
+ // Honor a seek requested before the adapter was ready. It may sit in either
178
+ // place: `pendingSeekRef` if the store subscription was mounted when requestSeek
179
+ // fired, or only in the store's `requestedSeekTime` if it fired earlier still
180
+ // (deep-link hydration runs before the player subscription mounts, so the request
181
+ // never reaches pendingSeekRef). Reconciling with the store here is what makes a
182
+ // deep-linked `?t=` land instead of starting at 0.
183
+ const storeSeek = usePlayerStore.getState().requestedSeekTime;
184
+ const seekTo = pendingSeekRef.current ?? storeSeek;
178
185
  pendingSeekRef.current = null;
186
+ if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
179
187
  const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
180
188
 
181
189
  adapter.seek(startTime);
@@ -8,6 +8,9 @@ interface MediaProbeResult {
8
8
 
9
9
  const cache = new Map<string, MediaProbeResult>();
10
10
  const inflight = new Map<string, Promise<MediaProbeResult | null>>();
11
+ // URLs whose probe failed (CORS, 404, non-media). Remembered so the rAF-driven
12
+ // timeline re-derive doesn't re-fetch them every frame and flood the console.
13
+ const failed = new Set<string>();
11
14
 
12
15
  let mediabunnyModule: typeof import("mediabunny") | null | false = null;
13
16
 
@@ -97,7 +100,8 @@ export async function probeMissingSourceDurations<
97
100
  el.src &&
98
101
  el.sourceDuration == null &&
99
102
  ["video", "audio"].includes(el.tag.toLowerCase()) &&
100
- !getCachedProbe(el.src),
103
+ !getCachedProbe(el.src) &&
104
+ !failed.has(normalizeUrl(el.src)),
101
105
  );
102
106
  if (needs.length === 0) return;
103
107
  await Promise.allSettled(
@@ -112,6 +116,7 @@ async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
112
116
  const key = normalizeUrl(url);
113
117
  const cached = cache.get(key);
114
118
  if (cached) return cached;
119
+ if (failed.has(key)) return null;
115
120
 
116
121
  let pending = inflight.get(key);
117
122
  if (pending) return pending;
@@ -119,6 +124,7 @@ async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
119
124
  pending = probeOne(key).then((result) => {
120
125
  inflight.delete(key);
121
126
  if (result) cache.set(key, result);
127
+ else failed.add(key);
122
128
  return result;
123
129
  });
124
130
  inflight.set(key, pending);
@@ -96,6 +96,14 @@ interface PlayerState {
96
96
  * (drag, resize, rotate) target this instead of recomputing from playhead. */
97
97
  activeKeyframePct: number | null;
98
98
  setActiveKeyframePct: (pct: number | null) => void;
99
+ /** Motion-path "set destination" mode. Armed from the preview toolbar (replaces
100
+ * the old double-click-on-canvas UX); while armed, one canvas click places the
101
+ * new path's destination. `available` is published by MotionPathOverlay so the
102
+ * toolbar shows the button only when the selected element can take a path. */
103
+ motionPathArmed: boolean;
104
+ setMotionPathArmed: (armed: boolean) => void;
105
+ motionPathCreateAvailable: boolean;
106
+ setMotionPathCreateAvailable: (available: boolean) => void;
99
107
 
100
108
  /** Multi-select: additional selected elements beyond selectedElementId. */
101
109
  selectedElementIds: Set<string>;
@@ -209,6 +217,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
209
217
 
210
218
  activeKeyframePct: null,
211
219
  setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
220
+ motionPathArmed: false,
221
+ setMotionPathArmed: (armed) => set({ motionPathArmed: armed }),
222
+ motionPathCreateAvailable: false,
223
+ setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
212
224
 
213
225
  selectedElementIds: new Set<string>(),
214
226
  toggleSelectedElementId: (id: string) =>
@@ -327,7 +339,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
327
339
  setTimelineReady: (ready) => set({ timelineReady: ready }),
328
340
  setBeatDragging: (dragging) => set({ beatDragging: dragging }),
329
341
  setElements: (elements) => set({ elements }),
330
- setSelectedElementId: (id) => set({ selectedElementId: id }),
342
+ setSelectedElementId: (id) =>
343
+ set((s) =>
344
+ // Selecting a different element drops any active keyframe selection — otherwise
345
+ // a stale activeKeyframePct from a prior diamond click would force the next drag
346
+ // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
347
+ // calling setSelectedElementId, so this never clobbers a genuine keyframe select.
348
+ id !== s.selectedElementId
349
+ ? { selectedElementId: id, activeKeyframePct: null, motionPathArmed: false }
350
+ : { selectedElementId: id },
351
+ ),
331
352
  updateElement: (elementId, updates) =>
332
353
  set((state) => ({
333
354
  elements: state.elements.map((el) =>
@@ -361,3 +382,19 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
361
382
  clipParentMap: new Map(),
362
383
  }),
363
384
  }));
385
+
386
+ // Bug-bash aid: expose the store so a reproduction can dump live state from the
387
+ // console, e.g. `__playerStore.getState().selectedElementId`. Harmless read
388
+ // handle; no behavioural effect.
389
+ // Only in dev. `import.meta.env` may be undefined in non-Vite bundlers (Next.js
390
+ // Turbopack), so guard the access like the telemetry client does.
391
+ function isDevBuild(): boolean {
392
+ try {
393
+ return import.meta.env.DEV === true;
394
+ } catch {
395
+ return false;
396
+ }
397
+ }
398
+ if (isDevBuild() && typeof window !== "undefined") {
399
+ (window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore;
400
+ }
@@ -5,6 +5,8 @@
5
5
  // localStorage.setItem('hyperframes-studio:telemetryDisabled','1')
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
+ import { generateId } from "../utils/generateId";
9
+
8
10
  const ANON_ID_KEY = "hyperframes-studio:anonymousId";
9
11
  const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
10
12
  const NOTICE_KEY = "hyperframes-studio:telemetryNoticeShown";
@@ -18,8 +20,7 @@ function safeLocalStorage(): Storage | null {
18
20
  }
19
21
 
20
22
  function newAnonymousId(): string {
21
- if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
22
- return `anon-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
23
+ return generateId();
23
24
  }
24
25
 
25
26
  export function getAnonymousId(): string {
@@ -0,0 +1,16 @@
1
+ // Gated strategic logging for the GSAP keyframe / manual-drag / gesture / razor
2
+ // edit flows. Silent in production; on in dev builds, or anywhere once you set
3
+ // `window.__hfDebug = true` in the console. Single `[hf-edit:<scope>]` prefix so
4
+ // the whole edit pipeline is greppable. Fires only at commit boundaries (user
5
+ // actions), never in render/raf loops, so it doesn't spam.
6
+ export function editLog(scope: string, ...args: unknown[]): void {
7
+ if (typeof window === "undefined") return;
8
+ const w = window as unknown as { __hfDebug?: boolean };
9
+ if (!import.meta.env.DEV && !w.__hfDebug) return;
10
+ // Stringify object args so the console prints their contents inline (`{x:1}`)
11
+ // instead of a collapsed `Object` — keeps the edit trail greppable/copyable.
12
+ const parts = args.map((a) =>
13
+ typeof a === "object" && a !== null ? JSON.stringify(a) : String(a),
14
+ );
15
+ console.debug(`[hf-edit:${scope}]`, ...parts);
16
+ }
@@ -0,0 +1,7 @@
1
+ // ponytail: crypto.randomUUID is undefined on plain HTTP non-localhost origins
2
+ export function generateId(): string {
3
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
4
+ return crypto.randomUUID();
5
+ }
6
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
7
+ }