@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.
- package/dist/assets/{hyperframes-player-GkN9yzzq.js → hyperframes-player-yABdb6yk.js} +1 -1
- package/dist/assets/{index-ByV72rWx.js → index-D77MIjSJ.js} +1 -1
- package/dist/assets/{index-BpKhiuzT.js → index-DWjF2Kxw.js} +1 -1
- package/dist/assets/{index-CyFqMHGS.js → index-Dt7lWtY7.js} +184 -184
- package/dist/assets/{index-t1LH6H5H.css → index-LrNlT5Tg.css} +1 -1
- package/dist/{chunk-J6Q572FA.js → chunk-246VB2G3.js} +3 -1
- package/dist/chunk-246VB2G3.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.html +2 -2
- package/dist/index.js +1611 -1310
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/FrameyGlyph.tsx +52 -0
- package/src/components/OpenInDesktopButton.dom.test.tsx +113 -0
- package/src/components/OpenInDesktopButton.tsx +153 -0
- package/src/components/StudioHeader.dom.test.tsx +2 -0
- package/src/components/StudioHeader.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +25 -0
- package/src/components/TimelineToolbar.tsx +5 -1
- package/src/components/editor/MotionPathNode.tsx +2 -1
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +99 -0
- package/src/components/editor/MotionPathOverlay.tsx +6 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +57 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +1 -1
- package/src/components/editor/motionPathGeometry.test.ts +10 -0
- package/src/components/editor/motionPathGeometry.ts +8 -5
- package/src/components/editor/motionPathLayerNode.test.ts +82 -0
- package/src/components/editor/motionPathLayerNode.ts +28 -0
- package/src/components/editor/useDomEditNudge.test.tsx +53 -1
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useMotionPathData.test.tsx +30 -0
- package/src/components/editor/useMotionPathData.ts +3 -1
- package/src/hooks/gsapValueAtPlayhead.test.ts +46 -3
- package/src/hooks/gsapValueAtPlayhead.ts +12 -2
- package/src/hooks/linkShortcuts.test.ts +16 -0
- package/src/hooks/linkShortcuts.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +1 -2
- package/src/index.ts +2 -0
- package/src/player/components/TimelineClipFades.tsx +1 -0
- package/src/player/components/timelineClipDragPreview.test.ts +68 -0
- package/src/player/components/timelineClipDragPreview.ts +39 -14
- package/src/player/components/timelineLayout.test.ts +24 -1
- package/src/player/components/timelineRulerGeometry.ts +24 -3
- package/src/player/components/timelineSnapping.test.ts +43 -12
- package/src/player/components/timelineSnapping.ts +33 -15
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +54 -54
- package/src/player/components/useTimelineClipDrag.ts +17 -29
- package/src/player/components/useTimelineProviderState.tsx +0 -1
- package/src/player/components/useTimelineTicks.ts +6 -3
- package/src/player/hooks/timelinePlayerTestHarness.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +66 -0
- package/src/player/hooks/useTimelinePlayer.ts +6 -2
- package/src/player/lib/time.ts +3 -0
- package/src/player/store/previewFrameStore.ts +48 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +51 -0
- package/src/styles/theme.css +3 -0
- package/src/timelineChromeExports.test.tsx +6 -0
- package/src/utils/gsapSoftReload.test.ts +50 -0
- package/src/utils/gsapSoftReload.ts +22 -2
- 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}
|
|
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
|
-
//
|
|
356
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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,
|
package/src/player/lib/time.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/styles/theme.css
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|