@hyperframes/studio 0.8.121 → 0.8.122
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-DMzBHhbH.js} +1 -1
- package/dist/assets/{index-CyFqMHGS.js → index-B-YDp8hN.js} +174 -174
- package/dist/assets/{index-BpKhiuzT.js → index-CBRX2eZX.js} +1 -1
- package/dist/assets/{index-ByV72rWx.js → index-lyjhSEzi.js} +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 +1 -1
- package/dist/index.js +416 -301
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- 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/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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { roundToCenti } from "../../utils/rounding";
|
|
2
3
|
|
|
3
|
-
export type TimelineSnapType = "beat" | "playhead" | "clip-edge";
|
|
4
|
+
export type TimelineSnapType = "beat" | "playhead" | "clip-edge" | "grid";
|
|
4
5
|
|
|
5
6
|
export interface TimelineSnapTarget {
|
|
6
7
|
time: number;
|
|
@@ -24,6 +25,7 @@ const TYPE_PRIORITY: Record<TimelineSnapType, number> = {
|
|
|
24
25
|
playhead: 0,
|
|
25
26
|
"clip-edge": 1,
|
|
26
27
|
beat: 2,
|
|
28
|
+
grid: 3,
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
export function collectTimelineSnapTargets(input: {
|
|
@@ -31,8 +33,6 @@ export function collectTimelineSnapTargets(input: {
|
|
|
31
33
|
playheadTime: number | null;
|
|
32
34
|
beatTimes: readonly number[];
|
|
33
35
|
excludeElementKey?: string | null;
|
|
34
|
-
/** A trim excludes the playhead: the dragged edge drives it, so snapping to it is circular. */
|
|
35
|
-
includePlayhead?: boolean;
|
|
36
36
|
}): TimelineSnapTarget[] {
|
|
37
37
|
const byTime = new Map<number, TimelineSnapTarget>();
|
|
38
38
|
const add = (time: number, type: TimelineSnapType) => {
|
|
@@ -50,16 +50,30 @@ export function collectTimelineSnapTargets(input: {
|
|
|
50
50
|
add(el.start, "clip-edge");
|
|
51
51
|
add(el.start + el.duration, "clip-edge");
|
|
52
52
|
}
|
|
53
|
-
if (input.playheadTime != null
|
|
54
|
-
add(input.playheadTime, "playhead");
|
|
53
|
+
if (input.playheadTime != null) add(input.playheadTime, "playhead");
|
|
55
54
|
|
|
56
55
|
return Array.from(byTime.values()).sort((a, b) => a.time - b.time);
|
|
57
56
|
}
|
|
58
57
|
|
|
58
|
+
/** Nearest ruler line in range, at its saved centisecond, and only within a pixel of it. */
|
|
59
|
+
function nearestGridLine(
|
|
60
|
+
time: number,
|
|
61
|
+
gridStep: number,
|
|
62
|
+
thresholdSecs: number,
|
|
63
|
+
): TimelineSnapTarget | null {
|
|
64
|
+
if (!(gridStep > 0)) return null;
|
|
65
|
+
const line = Math.round(time / gridStep) * gridStep;
|
|
66
|
+
const saved = roundToCenti(line);
|
|
67
|
+
const pixelsPerSecond = TIMELINE_SNAP_PX / thresholdSecs;
|
|
68
|
+
if (Math.abs(saved - line) * pixelsPerSecond >= 1) return null;
|
|
69
|
+
return Math.abs(saved - time) < thresholdSecs ? { time: saved, type: "grid" } : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
export function snapTimelineTime(
|
|
60
73
|
time: number,
|
|
61
74
|
targets: readonly TimelineSnapTarget[],
|
|
62
75
|
thresholdSecs: number,
|
|
76
|
+
gridStep = 0,
|
|
63
77
|
): { time: number; target: TimelineSnapTarget | null } {
|
|
64
78
|
let best: TimelineSnapTarget | null = null;
|
|
65
79
|
let bestDist = thresholdSecs;
|
|
@@ -73,6 +87,7 @@ export function snapTimelineTime(
|
|
|
73
87
|
best = target;
|
|
74
88
|
}
|
|
75
89
|
}
|
|
90
|
+
best ??= nearestGridLine(time, gridStep, thresholdSecs);
|
|
76
91
|
return best ? { time: best.time, target: best } : { time, target: null };
|
|
77
92
|
}
|
|
78
93
|
|
|
@@ -88,23 +103,26 @@ export function snapMoveToTargets(
|
|
|
88
103
|
targets: readonly TimelineSnapTarget[],
|
|
89
104
|
pixelsPerSecond: number,
|
|
90
105
|
timelineDuration: number,
|
|
106
|
+
gridStep = 0,
|
|
91
107
|
): { start: number; snapTime: number | null; snapType: TimelineSnapType | null } {
|
|
92
|
-
if (targets.length === 0) return { start, snapTime: null, snapType: null };
|
|
108
|
+
if (targets.length === 0 && !(gridStep > 0)) return { start, snapTime: null, snapType: null };
|
|
93
109
|
const thresholdSecs = TIMELINE_SNAP_PX / Math.max(pixelsPerSecond, 1);
|
|
94
|
-
const startSnap = snapTimelineTime(start, targets, thresholdSecs);
|
|
95
|
-
const endSnap = snapTimelineTime(start + duration, targets, thresholdSecs);
|
|
96
|
-
|
|
97
|
-
const
|
|
110
|
+
const startSnap = snapTimelineTime(start, targets, thresholdSecs, gridStep);
|
|
111
|
+
const endSnap = snapTimelineTime(start + duration, targets, thresholdSecs, gridStep);
|
|
112
|
+
// The nearer edge wins, but a grid line only when neither edge has a real target.
|
|
113
|
+
const rank = (snap: typeof startSnap, edgeTime: number) =>
|
|
114
|
+
snap.target === null
|
|
115
|
+
? Infinity
|
|
116
|
+
: Math.abs(snap.time - edgeTime) + (snap.target.type === "grid" ? thresholdSecs : 0);
|
|
117
|
+
const startRank = rank(startSnap, start);
|
|
118
|
+
const endRank = rank(endSnap, start + duration);
|
|
98
119
|
|
|
99
120
|
let candidate = start;
|
|
100
121
|
let target: TimelineSnapTarget | null = null;
|
|
101
|
-
if (
|
|
102
|
-
startMoved &&
|
|
103
|
-
(!endMoved || Math.abs(startSnap.time - start) <= Math.abs(endSnap.time - (start + duration)))
|
|
104
|
-
) {
|
|
122
|
+
if (startRank !== Infinity && startRank <= endRank) {
|
|
105
123
|
candidate = startSnap.time;
|
|
106
124
|
target = startSnap.target;
|
|
107
|
-
} else if (
|
|
125
|
+
} else if (endRank !== Infinity) {
|
|
108
126
|
candidate = endSnap.time - duration;
|
|
109
127
|
target = endSnap.target;
|
|
110
128
|
}
|
|
@@ -4,6 +4,7 @@ import React, { act } from "react";
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { TimelineElement } from "../store/playerStore";
|
|
6
6
|
import { usePlayerStore } from "../store/playerStore";
|
|
7
|
+
import { usePreviewFrameStore } from "../store/previewFrameStore";
|
|
7
8
|
import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
|
|
8
9
|
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
9
10
|
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
@@ -26,6 +27,7 @@ function el(id: string, over: Partial<TimelineElement> = {}): TimelineElement {
|
|
|
26
27
|
afterEach(() => {
|
|
27
28
|
document.body.innerHTML = "";
|
|
28
29
|
usePlayerStore.getState().reset();
|
|
30
|
+
usePreviewFrameStore.setState({ time: null });
|
|
29
31
|
});
|
|
30
32
|
|
|
31
33
|
function renderResizeHarness(
|
|
@@ -34,7 +36,6 @@ function renderResizeHarness(
|
|
|
34
36
|
options: {
|
|
35
37
|
wireGroupResize?: boolean;
|
|
36
38
|
snap?: boolean;
|
|
37
|
-
onSeek?: (time: number, seekOptions?: { keepPlaying?: boolean }) => void;
|
|
38
39
|
} = {},
|
|
39
40
|
) {
|
|
40
41
|
usePlayerStore.getState().setElements(elements);
|
|
@@ -65,7 +66,6 @@ function renderResizeHarness(
|
|
|
65
66
|
onMoveElement,
|
|
66
67
|
onBlockedEditAttempt,
|
|
67
68
|
onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements,
|
|
68
|
-
onSeek: options.onSeek,
|
|
69
69
|
setShowPopover: vi.fn(),
|
|
70
70
|
setRangeSelectionRef: { current: vi.fn() },
|
|
71
71
|
sessionEpoch,
|
|
@@ -451,16 +451,7 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
|
|
|
451
451
|
});
|
|
452
452
|
|
|
453
453
|
describe("useTimelineClipDrag — trim guide and preview frame", () => {
|
|
454
|
-
|
|
455
|
-
function trimEndWithSeekingPlayhead(state: { isPlaying?: boolean } = {}) {
|
|
456
|
-
usePlayerStore.setState({ currentTime: 1.25, ...state });
|
|
457
|
-
const onSeek = vi.fn((t: number) => usePlayerStore.setState({ currentTime: t }));
|
|
458
|
-
const a = el("a", { start: 1, duration: 2 });
|
|
459
|
-
const h = renderResizeHarness([a], [], { onSeek });
|
|
460
|
-
h.startResize(a, "end");
|
|
461
|
-
h.movePointer(50);
|
|
462
|
-
return { onSeek, h };
|
|
463
|
-
}
|
|
454
|
+
const previewFrame = () => usePreviewFrameStore.getState().time;
|
|
464
455
|
|
|
465
456
|
/** Clip a (0-2s) with neighbour b starting at 5s, snapping on, a's end edge grabbed. */
|
|
466
457
|
function trimAEndBesideB() {
|
|
@@ -487,45 +478,78 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
|
|
|
487
478
|
|
|
488
479
|
it("publishes no snap target when the trimmed edge is free", () => {
|
|
489
480
|
const h = trimAEndBesideB();
|
|
490
|
-
h.movePointer(
|
|
481
|
+
h.movePointer(162); // 3.62s: 12px from the 3.5s ruler line, 13px from 3.75s
|
|
491
482
|
expect(h.getResizingClip()).toMatchObject({ snapTime: null, snapType: null });
|
|
492
483
|
h.unmount();
|
|
493
484
|
});
|
|
494
485
|
|
|
495
|
-
it("
|
|
496
|
-
usePlayerStore.setState({ currentTime: 5 }); // no clip edge or beat nearby
|
|
486
|
+
it("snaps a trimmed edge to the playhead, which stays where it was", () => {
|
|
487
|
+
usePlayerStore.setState({ currentTime: 5.1 }); // off the ruler grid; no clip edge or beat nearby
|
|
497
488
|
const a = el("a", { start: 0, duration: 2 });
|
|
498
489
|
const h = renderResizeHarness([a], [], { snap: true });
|
|
499
490
|
h.startResize(a, "end");
|
|
500
|
-
h.movePointer(
|
|
491
|
+
h.movePointer(306); // a's end lands at 5.06s, 4px from the playhead
|
|
492
|
+
expect(h.getResizingClip()).toMatchObject({
|
|
493
|
+
snapTime: 5.1,
|
|
494
|
+
snapType: "playhead",
|
|
495
|
+
previewDuration: 5.1,
|
|
496
|
+
});
|
|
497
|
+
expect(usePlayerStore.getState().currentTime).toBe(5.1);
|
|
498
|
+
h.unmount();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("snaps a trimmed edge to the ruler's grid line when no clip edge is near", () => {
|
|
502
|
+
const h = trimAEndBesideB();
|
|
503
|
+
h.movePointer(152); // 3.52s, 2px from the 3.5s line (0.25s apart at 100px/s)
|
|
501
504
|
expect(h.getResizingClip()).toMatchObject({
|
|
502
|
-
snapTime:
|
|
503
|
-
snapType:
|
|
504
|
-
previewDuration:
|
|
505
|
+
snapTime: 3.5,
|
|
506
|
+
snapType: "grid",
|
|
507
|
+
previewDuration: 3.5,
|
|
505
508
|
});
|
|
506
509
|
h.unmount();
|
|
507
510
|
});
|
|
508
511
|
|
|
509
|
-
it("
|
|
512
|
+
it("does not snap to the grid with the magnet off", () => {
|
|
513
|
+
const a = el("a", { start: 0, duration: 2 });
|
|
514
|
+
const h = renderResizeHarness([a], []);
|
|
515
|
+
h.startResize(a, "end");
|
|
516
|
+
h.movePointer(152);
|
|
517
|
+
expect(h.getResizingClip()).toMatchObject({ snapTime: null, previewDuration: 3.52 });
|
|
518
|
+
h.unmount();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("shows the dragged edge's frame without moving the playhead, and drops it on release", async () => {
|
|
510
522
|
usePlayerStore.setState({ currentTime: 1.25 });
|
|
511
|
-
const onSeek = vi.fn();
|
|
512
523
|
const a = el("a", { start: 1, duration: 2 });
|
|
513
|
-
const h = renderResizeHarness([a], []
|
|
524
|
+
const h = renderResizeHarness([a], []);
|
|
514
525
|
h.startResize(a, "end");
|
|
515
526
|
h.movePointer(50);
|
|
516
|
-
expect(
|
|
527
|
+
expect(previewFrame()).toBeCloseTo(3.5 - 1 / 30, 6);
|
|
528
|
+
h.movePointer(120);
|
|
529
|
+
expect(usePlayerStore.getState().currentTime).toBe(1.25);
|
|
517
530
|
await h.dropPointer();
|
|
518
|
-
expect(
|
|
531
|
+
expect(previewFrame()).toBeNull();
|
|
532
|
+
expect(usePlayerStore.getState().currentTime).toBe(1.25);
|
|
533
|
+
h.unmount();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("drops the preview frame when the trim is cancelled", () => {
|
|
537
|
+
const a = el("a", { start: 1, duration: 2 });
|
|
538
|
+
const h = renderResizeHarness([a], []);
|
|
539
|
+
h.startResize(a, "end");
|
|
540
|
+
h.movePointer(50);
|
|
541
|
+
expect(previewFrame()).not.toBeNull();
|
|
542
|
+
h.pressEscape();
|
|
543
|
+
expect(previewFrame()).toBeNull();
|
|
519
544
|
h.unmount();
|
|
520
545
|
});
|
|
521
546
|
|
|
522
547
|
it("previews the new in-point when the start edge is trimmed", () => {
|
|
523
|
-
const onSeek = vi.fn();
|
|
524
548
|
const a = el("a", { start: 1, duration: 2 });
|
|
525
|
-
const h = renderResizeHarness([a], []
|
|
549
|
+
const h = renderResizeHarness([a], []);
|
|
526
550
|
h.startResize(a, "start");
|
|
527
551
|
h.movePointer(50);
|
|
528
|
-
expect(
|
|
552
|
+
expect(previewFrame()).toBe(1.5);
|
|
529
553
|
h.unmount();
|
|
530
554
|
});
|
|
531
555
|
|
|
@@ -540,41 +564,17 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
|
|
|
540
564
|
h.unmount();
|
|
541
565
|
});
|
|
542
566
|
|
|
543
|
-
it("
|
|
544
|
-
const { onSeek, h } = trimEndWithSeekingPlayhead();
|
|
545
|
-
h.movePointer(80);
|
|
546
|
-
h.movePointer(120);
|
|
547
|
-
await h.dropPointer();
|
|
548
|
-
expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true, follow: false });
|
|
549
|
-
h.unmount();
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
it("leaves the playhead where playback is when a trim is released while playing", async () => {
|
|
553
|
-
const { onSeek, h } = trimEndWithSeekingPlayhead({ isPlaying: true });
|
|
554
|
-
h.movePointer(120);
|
|
555
|
-
const callsBeforeRelease = onSeek.mock.calls.length;
|
|
556
|
-
await h.dropPointer();
|
|
557
|
-
expect(onSeek).toHaveBeenCalledTimes(callsBeforeRelease);
|
|
558
|
-
expect(onSeek).not.toHaveBeenCalledWith(1.25, expect.anything());
|
|
559
|
-
usePlayerStore.setState({ isPlaying: false });
|
|
560
|
-
h.unmount();
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
it("draws no guide and seeks to the rendered edge when a group member clamps the trim", () => {
|
|
564
|
-
const onSeek = vi.fn();
|
|
567
|
+
it("draws no guide and previews the rendered edge when a group member clamps the trim", () => {
|
|
565
568
|
const a = el("a", { start: 0, duration: 4 });
|
|
566
569
|
const b = el("b", { start: 0, duration: 1 });
|
|
567
570
|
const c = el("c", { start: 2, duration: 1 });
|
|
568
|
-
const h = renderResizeHarness([a, b, c], ["a", "b"], { snap: true
|
|
571
|
+
const h = renderResizeHarness([a, b, c], ["a", "b"], { snap: true });
|
|
569
572
|
h.startResize(a, "end");
|
|
570
573
|
h.movePointer(-198); // a's raw end 2.02s snaps to c at 2s; b clamps the shared delta
|
|
571
574
|
const clip = h.getResizingClip()!;
|
|
572
575
|
expect(clip.previewStart + clip.previewDuration).toBeCloseTo(3.1, 3);
|
|
573
576
|
expect(clip).toMatchObject({ snapTime: null, snapType: null });
|
|
574
|
-
expect(
|
|
575
|
-
keepPlaying: true,
|
|
576
|
-
follow: false,
|
|
577
|
-
});
|
|
577
|
+
expect(previewFrame()).toBeCloseTo(3.1 - 1 / 30, 3);
|
|
578
578
|
h.unmount();
|
|
579
579
|
});
|
|
580
580
|
});
|
|
@@ -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
|
+
}
|