@hyperframes/studio 0.8.121 → 0.8.123

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 (63) hide show
  1. package/dist/assets/{hyperframes-player-GkN9yzzq.js → hyperframes-player-yABdb6yk.js} +1 -1
  2. package/dist/assets/{index-ByV72rWx.js → index-D77MIjSJ.js} +1 -1
  3. package/dist/assets/{index-BpKhiuzT.js → index-DWjF2Kxw.js} +1 -1
  4. package/dist/assets/{index-CyFqMHGS.js → index-Dt7lWtY7.js} +184 -184
  5. package/dist/assets/{index-t1LH6H5H.css → index-LrNlT5Tg.css} +1 -1
  6. package/dist/{chunk-J6Q572FA.js → chunk-246VB2G3.js} +3 -1
  7. package/dist/chunk-246VB2G3.js.map +1 -0
  8. package/dist/index.d.ts +10 -2
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +1611 -1310
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +8 -8
  15. package/src/components/FrameyGlyph.tsx +52 -0
  16. package/src/components/OpenInDesktopButton.dom.test.tsx +113 -0
  17. package/src/components/OpenInDesktopButton.tsx +153 -0
  18. package/src/components/StudioHeader.dom.test.tsx +2 -0
  19. package/src/components/StudioHeader.tsx +2 -0
  20. package/src/components/TimelineToolbar.test.tsx +25 -0
  21. package/src/components/TimelineToolbar.tsx +5 -1
  22. package/src/components/editor/MotionPathNode.tsx +2 -1
  23. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +99 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +6 -0
  25. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +57 -0
  26. package/src/components/editor/domEditOverlayStartGesture.ts +1 -1
  27. package/src/components/editor/motionPathGeometry.test.ts +10 -0
  28. package/src/components/editor/motionPathGeometry.ts +8 -5
  29. package/src/components/editor/motionPathLayerNode.test.ts +82 -0
  30. package/src/components/editor/motionPathLayerNode.ts +28 -0
  31. package/src/components/editor/useDomEditNudge.test.tsx +53 -1
  32. package/src/components/editor/useDomEditNudge.ts +2 -2
  33. package/src/components/editor/useMotionPathData.test.tsx +30 -0
  34. package/src/components/editor/useMotionPathData.ts +3 -1
  35. package/src/hooks/gsapValueAtPlayhead.test.ts +46 -3
  36. package/src/hooks/gsapValueAtPlayhead.ts +12 -2
  37. package/src/hooks/linkShortcuts.test.ts +16 -0
  38. package/src/hooks/linkShortcuts.ts +2 -0
  39. package/src/hooks/useAppHotkeys.ts +1 -2
  40. package/src/index.ts +2 -0
  41. package/src/player/components/TimelineClipFades.tsx +1 -0
  42. package/src/player/components/timelineClipDragPreview.test.ts +68 -0
  43. package/src/player/components/timelineClipDragPreview.ts +39 -14
  44. package/src/player/components/timelineLayout.test.ts +24 -1
  45. package/src/player/components/timelineRulerGeometry.ts +24 -3
  46. package/src/player/components/timelineSnapping.test.ts +43 -12
  47. package/src/player/components/timelineSnapping.ts +33 -15
  48. package/src/player/components/useTimelineClipDrag.resize.test.tsx +54 -54
  49. package/src/player/components/useTimelineClipDrag.ts +17 -29
  50. package/src/player/components/useTimelineProviderState.tsx +0 -1
  51. package/src/player/components/useTimelineTicks.ts +6 -3
  52. package/src/player/hooks/timelinePlayerTestHarness.ts +2 -0
  53. package/src/player/hooks/useTimelinePlayer.seek.test.ts +66 -0
  54. package/src/player/hooks/useTimelinePlayer.ts +6 -2
  55. package/src/player/lib/time.ts +3 -0
  56. package/src/player/store/previewFrameStore.ts +48 -0
  57. package/src/player/store/timelineElement.ts +12 -0
  58. package/src/styles/components.css +51 -0
  59. package/src/styles/theme.css +3 -0
  60. package/src/timelineChromeExports.test.tsx +6 -0
  61. package/src/utils/gsapSoftReload.test.ts +50 -0
  62. package/src/utils/gsapSoftReload.ts +22 -2
  63. package/dist/chunk-J6Q572FA.js.map +0 -1
@@ -12,6 +12,8 @@ import {
12
12
  type TimelineGroupResizeSession,
13
13
  } from "./timelineGroupEditing";
14
14
  import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineSnapping";
15
+ import { getTimelineGridStep, rulerFrameRate } from "./timelineRulerGeometry";
16
+ import { setPreviewFrame } from "../store/previewFrameStore";
15
17
  import type { StackingPatch } from "./timelineStackingSync";
16
18
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
17
19
  import {
@@ -76,8 +78,6 @@ interface UseTimelineClipDragInput {
76
78
  onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
77
79
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
78
80
  onLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
79
- /** Seeks the preview; a trim shows the frame at its dragged edge. */
80
- onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
81
81
  setShowPopover: (show: boolean) => void;
82
82
  /** Stable ref to the range selection setter — wired after mount to break circular dependency. */
83
83
  setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
@@ -108,7 +108,6 @@ export function useTimelineClipDrag({
108
108
  onResizeElements,
109
109
  onBlockedEditAttempt,
110
110
  onLinkEdit,
111
- onSeek,
112
111
  setShowPopover,
113
112
  setRangeSelectionRef,
114
113
  readZIndex,
@@ -165,15 +164,11 @@ export function useTimelineClipDrag({
165
164
  const dragAudioTracksRef = useRef<ReadonlySet<number> | null>(null);
166
165
 
167
166
  const buildSnapTargets = useCallback(
168
- (
169
- excludeElementKey: string | null,
170
- includeBeats: boolean,
171
- includePlayhead = true,
172
- ): TimelineSnapTarget[] => {
167
+ (excludeElementKey: string | null, includeBeats: boolean): TimelineSnapTarget[] => {
173
168
  // Magnet off ⇒ no targets and no scan; do NOT cache so a mid-gesture toggle
174
169
  // back on starts scanning immediately (preserves the existing skip).
175
170
  if (!snapContextRef.current.enabled) return [];
176
- const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}|${includePlayhead ? 1 : 0}`;
171
+ const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}`;
177
172
  const cached = snapTargetsCacheRef.current.get(cacheKey);
178
173
  if (cached) return cached;
179
174
  const targets = collectTimelineSnapTargets({
@@ -181,13 +176,18 @@ export function useTimelineClipDrag({
181
176
  playheadTime: usePlayerStore.getState().currentTime,
182
177
  beatTimes: includeBeats ? snapContextRef.current.beatTimes : [],
183
178
  excludeElementKey,
184
- includePlayhead,
185
179
  });
186
180
  snapTargetsCacheRef.current.set(cacheKey, targets);
187
181
  return targets;
188
182
  },
189
183
  [],
190
184
  );
185
+ // The ruler's line spacing at the current zoom; 0 with the magnet off, like the targets.
186
+ const snapGridStep = useCallback(() => {
187
+ if (!snapContextRef.current.enabled) return 0;
188
+ const frameRate = rulerFrameRate(usePlayerStore.getState().timeDisplayMode);
189
+ return getTimelineGridStep(durationRef.current, ppsRef.current, frameRate);
190
+ }, [durationRef, ppsRef]);
191
191
 
192
192
  const [draggedClip, setDraggedClipState] = useState<DraggedClipState | null>(null);
193
193
  const draggedClipRef = useRef<DraggedClipState | null>(null);
@@ -285,10 +285,6 @@ export function useTimelineClipDrag({
285
285
  onResizeElementRef.current = onResizeElement;
286
286
  const onResizeElementsRef = useRef(onResizeElements);
287
287
  onResizeElementsRef.current = onResizeElements;
288
- const onSeekRef = useRef(onSeek);
289
- onSeekRef.current = onSeek;
290
- // Playhead time before the first trim preview seek; restored when the gesture ends.
291
- const trimSeekOriginRef = useRef<number | null>(null);
292
288
  const readZIndexRef = useRef(readZIndex);
293
289
  readZIndexRef.current = readZIndex;
294
290
  const onStackingPatchesRef = useRef(onStackingPatches);
@@ -325,6 +321,7 @@ export function useTimelineClipDrag({
325
321
  selectedKeys: gestureSelectedKeysRef.current,
326
322
  buildSnapTargets,
327
323
  audioTracks: dragAudioTracksRef.current,
324
+ gridStep: snapGridStep(),
328
325
  });
329
326
  },
330
327
  [
@@ -335,6 +332,7 @@ export function useTimelineClipDrag({
335
332
  trackInsertLayoutRef,
336
333
  rowGeometryRef,
337
334
  buildSnapTargets,
335
+ snapGridStep,
338
336
  ],
339
337
  );
340
338
 
@@ -349,15 +347,11 @@ export function useTimelineClipDrag({
349
347
  buildSnapTargets,
350
348
  elements: elementsRef.current,
351
349
  gestureKeys: gestureSelectedKeysRef.current,
350
+ gridStep: snapGridStep(),
352
351
  });
353
- trimSeekOriginRef.current ??= usePlayerStore.getState().currentTime;
354
352
  const setResizeState = (v: ResizePreviewResult) => {
355
- // A trim never changes the play state: keepPlaying lets seek() decide,
356
- // and it only resumes playback if it was already playing.
357
- onSeekRef.current?.(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration), {
358
- keepPlaying: true,
359
- follow: false,
360
- });
353
+ // The preview shows the dragged edge's frame; the playhead stays where it was.
354
+ setPreviewFrame(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration));
361
355
  publishResizingClip(
362
356
  resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
363
357
  );
@@ -393,7 +387,7 @@ export function useTimelineClipDrag({
393
387
  }
394
388
  previewGroupResize(session, next, setResizeState);
395
389
  },
396
- [scrollRef, ppsRef, buildSnapTargets, publishResizingClip],
390
+ [scrollRef, ppsRef, buildSnapTargets, snapGridStep, publishResizingClip],
397
391
  );
398
392
  const applyResizePointerRef = useRef(applyResizePointer);
399
393
  applyResizePointerRef.current = applyResizePointer;
@@ -404,13 +398,7 @@ export function useTimelineClipDrag({
404
398
  cancelAnimationFrame(clipDragScrollRaf.current);
405
399
  clipDragScrollRaf.current = 0;
406
400
  }
407
- if (trimSeekOriginRef.current != null) {
408
- // Paused: put the playhead back. Playing: leave it, a backward jump would rewind live playback.
409
- if (!usePlayerStore.getState().isPlaying) {
410
- onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true, follow: false });
411
- }
412
- trimSeekOriginRef.current = null;
413
- }
401
+ setPreviewFrame(null);
414
402
  // Gesture teardown: drop frozen caches so the next gesture reads fresh state.
415
403
  snapTargetsCacheRef.current.clear();
416
404
  dragAudioTracksRef.current = null;
@@ -223,7 +223,6 @@ export function useTimelineProviderState({
223
223
  onResizeElements: pinnedOnResizeElements,
224
224
  onBlockedEditAttempt: oneShotEdits.onBlockedEditAttempt,
225
225
  onLinkEdit: oneShotEdits.onLinkEdit,
226
- onSeek,
227
226
  setShowPopover,
228
227
  setRangeSelectionRef,
229
228
  readZIndex: zSyncEnabled ? readClipZIndex : undefined,
@@ -1,8 +1,11 @@
1
1
  import { useMemo } from "react";
2
- import { STUDIO_PREVIEW_FPS } from "../lib/time";
3
2
  import type { TimelineTimeDisplayMode } from "../../utils/studioUiPreferences";
4
3
  import type { TimelineTimeRange } from "../lib/timelineClipIndex";
5
- import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
4
+ import {
5
+ generateTicks,
6
+ getTimelineMajorTickInterval,
7
+ rulerFrameRate,
8
+ } from "./timelineRulerGeometry";
6
9
 
7
10
  export function useTimelineTicks(
8
11
  duration: number,
@@ -10,7 +13,7 @@ export function useTimelineTicks(
10
13
  timeDisplayMode: TimelineTimeDisplayMode,
11
14
  renderTimeRange?: TimelineTimeRange,
12
15
  ) {
13
- const frameRate = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
16
+ const frameRate = rulerFrameRate(timeDisplayMode);
14
17
  const ticks = useMemo(
15
18
  () => generateTicks(duration, pixelsPerSecond, frameRate, renderTimeRange),
16
19
  [duration, frameRate, pixelsPerSecond, renderTimeRange],
@@ -5,12 +5,14 @@ import { createRoot } from "react-dom/client";
5
5
  import { vi } from "vitest";
6
6
  import { useTimelinePlayer, type UseTimelinePlayerOptions } from "./useTimelinePlayer";
7
7
  import { usePlayerStore } from "../store/playerStore";
8
+ import { setPreviewFrame } from "../store/previewFrameStore";
8
9
 
9
10
  export type TimelinePlayerApi = ReturnType<typeof useTimelinePlayer>;
10
11
 
11
12
  export function resetPlayerStore() {
12
13
  usePlayerStore.getState().reset();
13
14
  usePlayerStore.setState({ requestedSeekTime: null });
15
+ setPreviewFrame(null);
14
16
  }
15
17
 
16
18
  function TimelinePlayerHarness({
@@ -12,6 +12,7 @@ import {
12
12
  resetPlayerStore,
13
13
  } from "./timelinePlayerTestHarness";
14
14
  import { liveTime, usePlayerStore } from "../store/playerStore";
15
+ import { setPreviewFrame } from "../store/previewFrameStore";
15
16
  import { setTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
16
17
 
17
18
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -350,6 +351,71 @@ describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
350
351
  });
351
352
  });
352
353
 
354
+ describe("useTimelinePlayer preview frame (a trim's dragged edge)", () => {
355
+ function previewFrame(time: number | null) {
356
+ act(() => setPreviewFrame(time));
357
+ }
358
+
359
+ it("shows the frame without moving the playhead, then puts the playhead's frame back", () => {
360
+ const { api, root, adapter } = renderAttachedTimelinePlayer();
361
+ seekWithAct(api, 1.25);
362
+ const seeks = liveTime.seekCount();
363
+ previewFrame(3.4);
364
+ expect(adapter.getTime()).toBe(3.4);
365
+ expect(usePlayerStore.getState().currentTime).toBe(1.25);
366
+ expect(liveTime.latest()).toBe(1.25);
367
+ previewFrame(null);
368
+ expect(adapter.getTime()).toBe(1.25);
369
+ expect(liveTime.seekCount()).toBe(seeks);
370
+ unmountWithAct(root);
371
+ });
372
+
373
+ it("keeps the playhead's time when paused, played or reloaded over the frame", () => {
374
+ const { getApi, root } = renderTimelinePlayerHarness();
375
+ attachIframeWindow(getApi(), makeAdapterWindow().win);
376
+ seekWithAct(getApi(), 1.25);
377
+ previewFrame(3.4);
378
+ act(() => getApi().pause());
379
+ expect(usePlayerStore.getState().currentTime).toBe(1.25);
380
+ act(() => getApi().refreshPlayer());
381
+ const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
382
+ const shadow = makeFakeIframe(makeAdapterWindow().win);
383
+ shadow.src = "http://localhost/api/projects/demo/preview?_t=1";
384
+ act(() => {
385
+ getApi().setShadowIframeNode(shadow);
386
+ getApi().onShadowIframeLoad(gen);
387
+ getApi().onShadowReadyChange(gen, true);
388
+ });
389
+ expect(getApi().iframeRef.current).toBe(shadow);
390
+ expect(usePlayerStore.getState().currentTime).toBe(1.25);
391
+ previewFrame(3.5);
392
+ const live = (shadow.contentWindow as unknown as { __player: { getTime(): number } }).__player;
393
+ expect(live.getTime()).toBe(3.5);
394
+ act(() => getApi().play());
395
+ expect(live.getTime()).toBe(1.25);
396
+ unmountWithAct(root);
397
+ });
398
+
399
+ it("plays from the in-point when play rewinds from the end over the frame", () => {
400
+ const { api, root, adapter } = renderAttachedTimelinePlayer();
401
+ seekWithAct(api, 30);
402
+ act(() => usePlayerStore.setState({ inPoint: 3 }));
403
+ previewFrame(12);
404
+ act(() => api.play());
405
+ expect(adapter.getTime()).toBe(3);
406
+ unmountWithAct(root);
407
+ });
408
+
409
+ it("leaves live playback alone", () => {
410
+ const { api, root, adapter } = renderAttachedTimelinePlayer();
411
+ seekWithAct(api, 2);
412
+ setStorePlaying();
413
+ previewFrame(5);
414
+ expect(adapter.getTime()).toBe(2);
415
+ unmountWithAct(root);
416
+ });
417
+ });
418
+
353
419
  describe("useTimelinePlayer RAF loop wrap-around", () => {
354
420
  type SeekCall = { time: number; options?: { keepPlaying?: boolean } };
355
421
 
@@ -5,6 +5,8 @@ import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
5
5
  import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
6
6
  import { useShadowPreviewReload } from "./useShadowPreviewReload";
7
7
  import { resolvePlaybackAdapter } from "./playbackAdapterResolution";
8
+ import { subscribePreviewFrame, transportAdapter } from "../store/previewFrameStore";
9
+ import { clampToDuration } from "../lib/time";
8
10
  import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
9
11
  import { logReload } from "../../utils/reloadDebug";
10
12
 
@@ -148,10 +150,11 @@ export function useTimelinePlayer({
148
150
  const iframe = overrideIframe !== undefined ? overrideIframe : iframeRef.current;
149
151
  const win = iframe?.contentWindow as IframeWindow | null;
150
152
  if (!iframe || !win) return null;
151
- return resolvePlaybackAdapter(iframe, win, {
153
+ const adapter = resolvePlaybackAdapter(iframe, win, {
152
154
  cache: staticSeekAdapterRef,
153
155
  warned: staticSeekWarnedRef,
154
156
  });
157
+ return transportAdapter(adapter);
155
158
  } catch {
156
159
  return null;
157
160
  }
@@ -301,7 +304,7 @@ export function useTimelinePlayer({
301
304
  return false;
302
305
  }
303
306
  const duration = Math.max(0, adapter.getDuration());
304
- const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
307
+ const nextTime = clampToDuration(time, duration);
305
308
  const keepPlaying = options?.keepPlaying === true;
306
309
  const shouldResumeAfterSeek = shouldResumeForwardPlaybackAfterSeek({
307
310
  keepPlaying,
@@ -366,6 +369,7 @@ export function useTimelinePlayer({
366
369
  }
367
370
  });
368
371
  }, [seek, play, pause]);
372
+ useEffect(() => subscribePreviewFrame(getAdapter), [getAdapter]);
369
373
  const { playbackKeyDownRef, playbackKeyUpRef, attachIframeShortcutListeners, togglePlay } =
370
374
  usePlaybackKeyboard({
371
375
  iframeRef,
@@ -1,5 +1,8 @@
1
1
  export const STUDIO_PREVIEW_FPS = 30;
2
2
 
3
+ export const clampToDuration = (time: number, duration: number) =>
4
+ Math.max(0, duration > 0 ? Math.min(duration, time) : time);
5
+
3
6
  export function formatTime(time: number): string {
4
7
  if (!Number.isFinite(time) || time < 0) return "00:00";
5
8
  const mins = Math.floor(time / 60);
@@ -0,0 +1,48 @@
1
+ import { create } from "zustand";
2
+ import type { PlaybackAdapter } from "../lib/playbackTypes";
3
+ import { clampToDuration } from "../lib/time";
4
+ import { usePlayerStore } from "./playerStore";
5
+
6
+ /** A frame the preview shows without moving the playhead (a trim's dragged edge); null shows the playhead's. */
7
+ export const usePreviewFrameStore = create<{ time: number | null }>(() => ({ time: null }));
8
+
9
+ export const setPreviewFrame = (time: number | null) => usePreviewFrameStore.setState({ time });
10
+
11
+ // A trim's edge frame is on screen, not the transport's time: pause, play and a reload's
12
+ // hand-over read the playhead's time, and play starts from it unless the caller seeked first.
13
+ function showingPreviewFrame(adapter: PlaybackAdapter): PlaybackAdapter {
14
+ let seeked = false;
15
+ const time = () => (seeked ? adapter.getTime() : usePlayerStore.getState().currentTime);
16
+ return {
17
+ play: () => {
18
+ adapter.seek(time());
19
+ adapter.play();
20
+ },
21
+ pause: () => adapter.pause(),
22
+ seek: (t, options) => {
23
+ seeked = true;
24
+ adapter.seek(t, options);
25
+ },
26
+ getTime: time,
27
+ getDuration: () => adapter.getDuration(),
28
+ isPlaying: () => adapter.isPlaying(),
29
+ };
30
+ }
31
+
32
+ /** The adapter as the transport must see it while a paused preview frame is on screen. */
33
+ export function transportAdapter(adapter: PlaybackAdapter | null): PlaybackAdapter | null {
34
+ const showing = usePreviewFrameStore.getState().time !== null;
35
+ return adapter && showing && !usePlayerStore.getState().isPlaying
36
+ ? showingPreviewFrame(adapter)
37
+ : adapter;
38
+ }
39
+
40
+ /** Puts each preview frame on screen while paused, and the playhead's frame back when it clears. */
41
+ export function subscribePreviewFrame(getAdapter: () => PlaybackAdapter | null): () => void {
42
+ return usePreviewFrameStore.subscribe((state, prev) => {
43
+ if (state.time === prev.time || usePlayerStore.getState().isPlaying) return;
44
+ const adapter = getAdapter();
45
+ const time = state.time ?? usePlayerStore.getState().currentTime;
46
+ adapter?.seek(clampToDuration(time, adapter.getDuration()));
47
+ });
48
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ClipManifestClip } from "../lib/playbackTypes";
11
+ import { roundToCenti } from "../../utils/rounding";
11
12
 
12
13
  export interface TimelineElement {
13
14
  id: string;
@@ -112,9 +113,20 @@ type RowClock = Pick<
112
113
  TimelineElement,
113
114
  "start" | "parentCompositionStart" | "authoredStartIsMasterTime"
114
115
  >;
116
+ type SavedClip = RowClock & Pick<TimelineElement, "duration">;
115
117
  const authoredOffset = (element: RowClock) =>
116
118
  element.authoredStartIsMasterTime ? 0 : (element.parentCompositionStart ?? 0);
117
119
 
120
+ /**
121
+ * Where a clip's edges sit once saved: its file stores the local start to the centisecond, and the
122
+ * duration too when a resize writes it; a move keeps the authored duration.
123
+ */
124
+ export function savedClipEdges(element: SavedClip, start: number, resizedDuration?: number) {
125
+ const savedStart = authoredOffset(element) + roundToCenti(toAuthoredStart(element, start));
126
+ const duration = resizedDuration === undefined ? element.duration : roundToCenti(resizedDuration);
127
+ return { start: savedStart, end: savedStart + duration };
128
+ }
129
+
118
130
  /** The earliest master start this row can take: its host's start, or its own if already earlier. */
119
131
  export function clampToHostStart(element: RowClock, masterTime: number): number {
120
132
  return Math.max(Math.min(authoredOffset(element), element.start), masterTime);
@@ -935,3 +935,54 @@
935
935
  transform: scaleY(1);
936
936
  }
937
937
  }
938
+
939
+ /* Framey on the header's "Edit with Framey" (OpenInDesktopButton.tsx): a wiggle on hover, a flight towards
940
+ * the app while it opens. The eye follows the pointer from script. */
941
+ @keyframes hf-framey-wiggle {
942
+ 0%,
943
+ 100% {
944
+ transform: rotate(0deg);
945
+ }
946
+ 25% {
947
+ transform: rotate(-14deg);
948
+ }
949
+ 60% {
950
+ transform: rotate(10deg);
951
+ }
952
+ }
953
+
954
+ @keyframes hf-framey-fly {
955
+ 70% {
956
+ opacity: 1;
957
+ }
958
+ 100% {
959
+ transform: translate(48px, -28px) rotate(25deg) scale(0.6);
960
+ opacity: 0;
961
+ }
962
+ }
963
+
964
+ .hf-framey {
965
+ transform-origin: 30% 30%;
966
+ }
967
+
968
+ .hf-framey-eye {
969
+ transition: transform 80ms linear;
970
+ }
971
+
972
+ .hf-framey-trigger:hover .hf-framey {
973
+ animation: hf-framey-wiggle 0.5s ease-in-out;
974
+ }
975
+
976
+ .hf-framey-trigger[data-opening] .hf-framey {
977
+ animation: hf-framey-fly 0.9s ease-in forwards;
978
+ }
979
+
980
+ @media (prefers-reduced-motion: reduce) {
981
+ .hf-framey-trigger:hover .hf-framey,
982
+ .hf-framey-trigger[data-opening] .hf-framey {
983
+ animation: none;
984
+ }
985
+ .hf-framey-eye {
986
+ transition: none;
987
+ }
988
+ }
@@ -436,6 +436,9 @@
436
436
  --color-violet-400: oklch(70.2% 0.183 293.541);
437
437
  --color-violet-500: oklch(60.6% 0.25 292.717);
438
438
  --color-white: #fff;
439
+ /* Framey, the desktop app's cursor mascot (hyperframes-internal packages/nib): the same in both themes. */
440
+ --color-framey: #3ce6ac;
441
+ --color-framey-ink: #16121a;
439
442
  }
440
443
 
441
444
  /* The colour-scheme picks each token's light or dark half: dark unless the document element
@@ -4,6 +4,7 @@ import { act } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import {
6
6
  AudioMeterStrip,
7
+ dispatchLinkShortcut,
7
8
  TimelineHistoryButtons,
8
9
  TimelineToolbar,
9
10
  useAudioMetersVisible as exportedAudioMetersVisible,
@@ -13,6 +14,7 @@ import {
13
14
  } from "@hyperframes/studio";
14
15
  import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
15
16
  import { useAudioMetersVisible } from "./utils/audioMeterVisibility";
17
+ import { dispatchLinkShortcut as localDispatchLinkShortcut } from "./hooks/linkShortcuts";
16
18
 
17
19
  installReactActEnvironment();
18
20
 
@@ -52,6 +54,10 @@ describe("timeline chrome package exports, outside Studio's shell", () => {
52
54
  await act(async () => root.unmount());
53
55
  });
54
56
 
57
+ it("exports the clip menu's link shortcuts for a host's own key handler", () => {
58
+ expect(dispatchLinkShortcut).toBe(localDispatchLinkShortcut);
59
+ });
60
+
55
61
  it("exports the meter visibility store a host toggles", () => {
56
62
  expect(exportedAudioMetersVisible).toBe(useAudioMetersVisible);
57
63
  exportedAudioMetersVisible.getState().setVisible(true);
@@ -384,6 +384,56 @@ describe("ensureMotionPathPluginLoaded", () => {
384
384
  expect(appendedScripts).toHaveLength(0);
385
385
  });
386
386
 
387
+ describe("when gsap is not there yet at load", () => {
388
+ const post = (source: unknown, type = "ready") => {
389
+ const event = new MessageEvent("message", { data: { source: "hf-preview", type } });
390
+ Object.defineProperty(event, "source", { value: source });
391
+ window.dispatchEvent(event);
392
+ };
393
+
394
+ it("loads the plugin once that iframe's runtime is ready", () => {
395
+ const { iframe, contentWindow, appendedScripts } = buildBootstrapIframe({ gsap: undefined });
396
+ ensureMotionPathPluginLoaded(iframe);
397
+ contentWindow.gsap = { registerPlugin: vi.fn() };
398
+ post({});
399
+ post(contentWindow, "timeline");
400
+ expect(appendedScripts).toHaveLength(0);
401
+ post(contentWindow);
402
+ post(contentWindow);
403
+ expect(appendedScripts).toHaveLength(1);
404
+ expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
405
+ });
406
+
407
+ it("stops listening at the first ready, even when it brought no gsap", () => {
408
+ const { iframe, contentWindow, appendedScripts } = buildBootstrapIframe({ gsap: undefined });
409
+ ensureMotionPathPluginLoaded(iframe);
410
+ post(contentWindow);
411
+ contentWindow.gsap = { registerPlugin: vi.fn() };
412
+ post(contentWindow);
413
+ expect(appendedScripts).toHaveLength(0);
414
+ });
415
+
416
+ it("drops a preview that never became ready once a newer one loads", () => {
417
+ const stale = buildBootstrapIframe({ gsap: undefined });
418
+ ensureMotionPathPluginLoaded(stale.iframe);
419
+ ensureMotionPathPluginLoaded(buildBootstrapIframe({ gsap: undefined }).iframe);
420
+ stale.contentWindow.gsap = { registerPlugin: vi.fn() };
421
+ post(stale.contentWindow);
422
+ expect(stale.appendedScripts).toHaveLength(0);
423
+ });
424
+
425
+ it("does not wait when the runtime already booted, since its ready has gone out", () => {
426
+ const { iframe, contentWindow, appendedScripts } = buildBootstrapIframe({
427
+ gsap: undefined,
428
+ __playerReady: true,
429
+ });
430
+ ensureMotionPathPluginLoaded(iframe);
431
+ contentWindow.gsap = { registerPlugin: vi.fn() };
432
+ post(contentWindow);
433
+ expect(appendedScripts).toHaveLength(0);
434
+ });
435
+ });
436
+
387
437
  it("loads the plugin at the composition's own gsap version", () => {
388
438
  const { iframe, appendedScripts } = buildBootstrapIframe({
389
439
  gsap: { version: "3.14.2", registerPlugin: vi.fn() },
@@ -14,6 +14,7 @@ type IframeWindow = Window & {
14
14
  // reloads (each needing the plugin) don't queue duplicate plugin scripts that
15
15
  // re-flash the iframe. Cleared once the plugin loads or errors.
16
16
  __hfMotionPathPluginLoading?: boolean;
17
+ __playerReady?: boolean;
17
18
  gsap?: {
18
19
  version?: string;
19
20
  timeline?: (...args: unknown[]) => unknown;
@@ -37,10 +38,15 @@ type IframeWindow = Window & {
37
38
  * studio edits.
38
39
  *
39
40
  * Idempotent (no-ops once the plugin is present or already loading) and
40
- * defensive: no-ops without gsap/registerPlugin and tolerates a CDN failure
41
+ * defensive: without gsap it waits for the runtime's ready, and it tolerates a CDN failure
41
42
  * (the soft-reload async fallback in applySoftReload still covers that case).
42
43
  */
44
+ // At most one preview waits for its runtime; a newer load replaces it, so a frame that never boots is not kept alive.
45
+ let pendingReadyRetry: ((event: MessageEvent) => void) | null = null;
46
+
43
47
  export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null): void {
48
+ if (pendingReadyRetry) window.removeEventListener("message", pendingReadyRetry);
49
+ pendingReadyRetry = null;
44
50
  if (!iframe?.contentWindow || !iframe.contentDocument) return;
45
51
  const win = iframe.contentWindow as IframeWindow;
46
52
  const doc = iframe.contentDocument;
@@ -53,7 +59,21 @@ export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null):
53
59
  } catch {}
54
60
  return;
55
61
  }
56
- if (!win.gsap?.registerPlugin) return;
62
+ // A body gsap runs after web fonts, which can be after the iframe's load: retry once the runtime is ready.
63
+ // A booted runtime (`__playerReady`, set in the same task it posts ready) has nothing left to wait for.
64
+ if (!win.gsap?.registerPlugin) {
65
+ if (win.__playerReady) return;
66
+ const retry = (event: MessageEvent) => {
67
+ const data = event.data as { source?: unknown; type?: unknown } | null;
68
+ if (event.source !== win || data?.source !== "hf-preview" || data.type !== "ready") return;
69
+ window.removeEventListener("message", retry);
70
+ pendingReadyRetry = null;
71
+ if (win.gsap?.registerPlugin) ensureMotionPathPluginLoaded(iframe);
72
+ };
73
+ pendingReadyRetry = retry;
74
+ window.addEventListener("message", retry);
75
+ return;
76
+ }
57
77
  // A load is already in flight for this iframe — don't queue a second script.
58
78
  if (win.__hfMotionPathPluginLoading) return;
59
79