@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
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
resolveTimelineMinDuration,
|
|
6
6
|
} from "./timelineGroupEditing";
|
|
7
7
|
import type { TimelineElement } from "../store/playerStore";
|
|
8
|
-
import { clampToHostStart } from "../store/timelineElement";
|
|
8
|
+
import { clampToHostStart, savedClipEdges } from "../store/timelineElement";
|
|
9
9
|
import {
|
|
10
10
|
CLIP_Y,
|
|
11
11
|
TRACK_H,
|
|
@@ -34,7 +34,6 @@ import { heldAudioShiftRange, heldPartnerVideoBounds } from "./audioClipLink";
|
|
|
34
34
|
type BuildSnapTargets = (
|
|
35
35
|
excludeElementKey: string | null,
|
|
36
36
|
includeBeats: boolean,
|
|
37
|
-
includePlayhead?: boolean,
|
|
38
37
|
) => TimelineSnapTarget[];
|
|
39
38
|
|
|
40
39
|
export interface DragPreviewContext {
|
|
@@ -56,6 +55,8 @@ export interface DragPreviewContext {
|
|
|
56
55
|
* on demand from `elements`, so the result is identical either way.
|
|
57
56
|
*/
|
|
58
57
|
audioTracks?: ReadonlySet<number>;
|
|
58
|
+
/** Seconds between the ruler's lines; 0 or absent when snapping is off. */
|
|
59
|
+
gridStep?: number;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export function createKeyboardClipDrag(
|
|
@@ -248,6 +249,7 @@ export function computeDragPreview(
|
|
|
248
249
|
// Relaxed clamp: allow the snapped start past the content, up to the
|
|
249
250
|
// rendered extent (see dragMaxStart) — the composition grows on commit.
|
|
250
251
|
dragMaxStart + drag.element.duration,
|
|
252
|
+
ctx.gridStep,
|
|
251
253
|
);
|
|
252
254
|
// A group moves rigidly: the grabbed clip stops where any mover would cross its host's start.
|
|
253
255
|
const group = resolveGroupDrag(drag, ctx);
|
|
@@ -266,6 +268,11 @@ export function computeDragPreview(
|
|
|
266
268
|
return { ...drag, started: true };
|
|
267
269
|
}
|
|
268
270
|
const { track: previewTrack, insertRow } = placement;
|
|
271
|
+
const target =
|
|
272
|
+
placement.start === snap.start && snap.snapTime !== null && snap.snapType !== null
|
|
273
|
+
? { time: snap.snapTime, type: snap.snapType }
|
|
274
|
+
: null;
|
|
275
|
+
const guide = guideIfSaved(drag.element, { start: snap.start }, target, pps);
|
|
269
276
|
return {
|
|
270
277
|
...drag,
|
|
271
278
|
started: true,
|
|
@@ -277,11 +284,24 @@ export function computeDragPreview(
|
|
|
277
284
|
// tell a deliberate vertical lane change from a horizontal drag.
|
|
278
285
|
desiredTrack: nextMove.track,
|
|
279
286
|
insertRow,
|
|
280
|
-
snapTime:
|
|
281
|
-
snapType:
|
|
287
|
+
snapTime: guide?.time ?? null,
|
|
288
|
+
snapType: guide?.type ?? null,
|
|
282
289
|
};
|
|
283
290
|
}
|
|
284
291
|
|
|
292
|
+
/** A snap's guide, kept only when the clip's edge as its file will save it is within a pixel of it. */
|
|
293
|
+
export function guideIfSaved(
|
|
294
|
+
element: TimelineElement,
|
|
295
|
+
clip: { start: number; duration?: number },
|
|
296
|
+
target: TimelineSnapTarget | null,
|
|
297
|
+
pps: number,
|
|
298
|
+
): TimelineSnapTarget | null {
|
|
299
|
+
if (!target) return null;
|
|
300
|
+
const saved = savedClipEdges(element, clip.start, clip.duration);
|
|
301
|
+
const off = Math.min(Math.abs(saved.start - target.time), Math.abs(saved.end - target.time));
|
|
302
|
+
return off * pps < 1 ? target : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
285
305
|
/** One frame: the last visible frame of a clip sits just before its end time. */
|
|
286
306
|
const TRIM_END_FRAME_LEAD_S = 1 / STUDIO_PREVIEW_FPS;
|
|
287
307
|
|
|
@@ -296,6 +316,8 @@ export interface ResizePreviewContext {
|
|
|
296
316
|
buildSnapTargets: BuildSnapTargets;
|
|
297
317
|
elements?: readonly TimelineElement[];
|
|
298
318
|
gestureKeys?: ReadonlySet<string>;
|
|
319
|
+
/** Seconds between the ruler's lines; 0 or absent when snapping is off. */
|
|
320
|
+
gridStep?: number;
|
|
299
321
|
}
|
|
300
322
|
|
|
301
323
|
export interface ResizePreviewResult {
|
|
@@ -362,21 +384,19 @@ export function computeResizePreview(
|
|
|
362
384
|
effectiveClientX,
|
|
363
385
|
);
|
|
364
386
|
|
|
365
|
-
// Snap
|
|
366
|
-
//
|
|
367
|
-
// limits resolveTimelineResize enforces. The music track defines the
|
|
368
|
-
// beats, so it must not snap to them, but still snaps to clip edges.
|
|
387
|
+
// Snap within the same limits resolveTimelineResize enforces. The music
|
|
388
|
+
// track defines the beats, so it must not snap to them.
|
|
369
389
|
const trimTargets = buildSnapTargets(
|
|
370
390
|
resize.element.key ?? resize.element.id,
|
|
371
391
|
!isMusicTrack(resize.element),
|
|
372
|
-
false,
|
|
373
392
|
);
|
|
393
|
+
const gridStep = ctx.gridStep ?? 0;
|
|
374
394
|
let snap: TimelineSnapTarget | null = null;
|
|
375
|
-
if (trimTargets.length > 0) {
|
|
395
|
+
if (trimTargets.length > 0 || gridStep > 0) {
|
|
376
396
|
const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1);
|
|
377
397
|
if (resize.edge === "end") {
|
|
378
398
|
const edgeTime = nextResize.start + nextResize.duration;
|
|
379
|
-
const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs);
|
|
399
|
+
const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs, gridStep);
|
|
380
400
|
// Stay within [start+minDuration, maxEnd] so the snap can't create a
|
|
381
401
|
// degenerate clip or run past the source/composition limit.
|
|
382
402
|
const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
|
|
@@ -387,16 +407,21 @@ export function computeResizePreview(
|
|
|
387
407
|
) {
|
|
388
408
|
// An edge already on the target still owns the guide; only move it when off.
|
|
389
409
|
if (snapped !== edgeTime) nextResize = { ...nextResize, duration: snappedDuration };
|
|
390
|
-
snap = target;
|
|
410
|
+
snap = guideIfSaved(resize.element, nextResize, target, pps);
|
|
391
411
|
}
|
|
392
412
|
} else {
|
|
393
|
-
const { time: snapped, target } = snapTimelineTime(
|
|
413
|
+
const { time: snapped, target } = snapTimelineTime(
|
|
414
|
+
nextResize.start,
|
|
415
|
+
trimTargets,
|
|
416
|
+
snapSecs,
|
|
417
|
+
gridStep,
|
|
418
|
+
);
|
|
394
419
|
const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
|
|
395
420
|
const delta = snapped - nextResize.start;
|
|
396
421
|
const bounds = clipStartTrimDeltaBounds(clip, minStart, resolveTimelineMinDuration());
|
|
397
422
|
if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
|
|
398
423
|
if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
|
|
399
|
-
snap = target;
|
|
424
|
+
snap = guideIfSaved(resize.element, nextResize, target, pps);
|
|
400
425
|
}
|
|
401
426
|
}
|
|
402
427
|
}
|
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
resolveTimelineAssetDrop,
|
|
19
19
|
getTimelineBeatEntries,
|
|
20
20
|
} from "./timelineLayout";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
generateTicks,
|
|
23
|
+
getTimelineGridStep,
|
|
24
|
+
getTimelineMajorTickInterval,
|
|
25
|
+
} from "./timelineRulerGeometry";
|
|
22
26
|
import { resolveInsertRow } from "./timelineCollision";
|
|
23
27
|
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
|
|
24
28
|
|
|
@@ -42,6 +46,25 @@ describe("horizontal timeline window", () => {
|
|
|
42
46
|
expect(ticks.major.length + ticks.minor.length).toBeLessThan(100);
|
|
43
47
|
});
|
|
44
48
|
|
|
49
|
+
it("spaces the snap grid exactly like the ruler's drawn lines at each zoom", () => {
|
|
50
|
+
for (const [pps, fps] of [
|
|
51
|
+
[100, undefined],
|
|
52
|
+
[12, undefined],
|
|
53
|
+
[900, undefined],
|
|
54
|
+
[100, 30],
|
|
55
|
+
[3, 30],
|
|
56
|
+
] as const) {
|
|
57
|
+
const { major, minor } = generateTicks(60, pps, fps);
|
|
58
|
+
const lines = [...major, ...minor].sort((a, b) => a - b);
|
|
59
|
+
expect(getTimelineGridStep(60, pps, fps)).toBeCloseTo(lines[1]! - lines[0]!, 6);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("has no snap grid where the ruler draws no lines", () => {
|
|
64
|
+
expect(generateTicks(20_000, 100)).toEqual({ major: [], minor: [] });
|
|
65
|
+
expect(getTimelineGridStep(20_000, 100)).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
45
68
|
it("slices beat records with original strength indexes and unions a pinned beat", () => {
|
|
46
69
|
expect(
|
|
47
70
|
getTimelineBeatEntries(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { formatTime } from "../lib/time";
|
|
1
|
+
import { formatTime, STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
2
|
+
import type { TimelineTimeDisplayMode } from "../../utils/studioUiPreferences";
|
|
2
3
|
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
3
4
|
|
|
4
5
|
// fallow-ignore-next-line complexity
|
|
@@ -87,6 +88,27 @@ function appendMinorTicks(
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
function tickSpacing(duration: number, pixelsPerSecond?: number, frameRate?: number) {
|
|
92
|
+
const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate);
|
|
93
|
+
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
|
|
94
|
+
return { majorInterval, subdivisions };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function rulerFrameRate(mode: TimelineTimeDisplayMode): number | undefined {
|
|
98
|
+
return mode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Seconds between neighbouring ruler lines (minor ticks when drawn, else major): the snap grid. */
|
|
102
|
+
export function getTimelineGridStep(
|
|
103
|
+
duration: number,
|
|
104
|
+
pixelsPerSecond?: number,
|
|
105
|
+
frameRate?: number,
|
|
106
|
+
): number {
|
|
107
|
+
if (!isSupportedTickDuration(duration)) return 0;
|
|
108
|
+
const { majorInterval, subdivisions } = tickSpacing(duration, pixelsPerSecond, frameRate);
|
|
109
|
+
return subdivisions > 0 ? majorInterval / subdivisions : majorInterval;
|
|
110
|
+
}
|
|
111
|
+
|
|
90
112
|
export function generateTicks(
|
|
91
113
|
duration: number,
|
|
92
114
|
pixelsPerSecond?: number,
|
|
@@ -94,8 +116,7 @@ export function generateTicks(
|
|
|
94
116
|
range?: TimelineTimeRange,
|
|
95
117
|
): { major: number[]; minor: number[] } {
|
|
96
118
|
if (!isSupportedTickDuration(duration)) return { major: [], minor: [] };
|
|
97
|
-
const majorInterval =
|
|
98
|
-
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
|
|
119
|
+
const { majorInterval, subdivisions } = tickSpacing(duration, pixelsPerSecond, frameRate);
|
|
99
120
|
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
|
|
100
121
|
const major: number[] = [];
|
|
101
122
|
const minor: number[] = [];
|
|
@@ -27,18 +27,6 @@ describe("collectTimelineSnapTargets", () => {
|
|
|
27
27
|
expect(targets).toContainEqual({ time: 0.5, type: "beat" });
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
it("omits the playhead when includePlayhead is false, for a trim", () => {
|
|
31
|
-
const targets = collectTimelineSnapTargets({
|
|
32
|
-
elements,
|
|
33
|
-
playheadTime: 7.25,
|
|
34
|
-
beatTimes: [0.5],
|
|
35
|
-
includePlayhead: false,
|
|
36
|
-
});
|
|
37
|
-
expect(targets.some((t) => t.type === "playhead")).toBe(false);
|
|
38
|
-
expect(targets).toContainEqual({ time: 2, type: "clip-edge" });
|
|
39
|
-
expect(targets).toContainEqual({ time: 0.5, type: "beat" });
|
|
40
|
-
});
|
|
41
|
-
|
|
42
30
|
it("excludes the dragged element's own edges", () => {
|
|
43
31
|
const targets = collectTimelineSnapTargets({
|
|
44
32
|
elements,
|
|
@@ -90,6 +78,35 @@ describe("snapTimelineTime", () => {
|
|
|
90
78
|
it("returns input unchanged when nothing is within threshold", () => {
|
|
91
79
|
expect(snapTimelineTime(6, targets, 0.1)).toEqual({ time: 6, target: null });
|
|
92
80
|
});
|
|
81
|
+
|
|
82
|
+
it("snaps to the nearest ruler line when no target is in range", () => {
|
|
83
|
+
expect(snapTimelineTime(6.27, targets, 0.08, 0.25)).toEqual({
|
|
84
|
+
time: 6.25,
|
|
85
|
+
target: { time: 6.25, type: "grid" },
|
|
86
|
+
});
|
|
87
|
+
expect(snapTimelineTime(6.37, targets, 0.08, 0.25)).toEqual({ time: 6.37, target: null });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("prefers a target in range over a nearer ruler line", () => {
|
|
91
|
+
expect(snapTimelineTime(5.24, targets, 0.08, 0.25).target).toEqual({
|
|
92
|
+
time: 5.3,
|
|
93
|
+
type: "playhead",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("snaps a line at its saved centisecond, and not at all when that is a pixel or more off", () => {
|
|
98
|
+
// 187.5 px/s: 4.625s saves as 4.63s, under a pixel away, so the guide sits where the clip lands.
|
|
99
|
+
expect(snapTimelineTime(4.627, [], 8 / 187.5, 0.125).target).toEqual({
|
|
100
|
+
time: 4.63,
|
|
101
|
+
type: "grid",
|
|
102
|
+
});
|
|
103
|
+
// 1440 px/s: 1.025s would save 7px off its line, so the line does not snap.
|
|
104
|
+
expect(snapTimelineTime(1.026, [], 8 / 1440, 0.025)).toEqual({ time: 1.026, target: null });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("lands on whole frames for a frame-spaced grid", () => {
|
|
108
|
+
expect(snapTimelineTime(1.01, [], 0.08, 1 / 30).time).toBe(1);
|
|
109
|
+
});
|
|
93
110
|
});
|
|
94
111
|
|
|
95
112
|
describe("snapMoveToTargets", () => {
|
|
@@ -120,6 +137,20 @@ describe("snapMoveToTargets", () => {
|
|
|
120
137
|
expect(snapMoveToTargets(5.5, 2, targets, 1000, 60).snapTime).toBeNull();
|
|
121
138
|
});
|
|
122
139
|
|
|
140
|
+
it("snaps a clip edge to the ruler grid when no target is near", () => {
|
|
141
|
+
expect(snapMoveToTargets(1.02, 2, [], 100, 60, 0.25)).toEqual({
|
|
142
|
+
start: 1,
|
|
143
|
+
snapTime: 1,
|
|
144
|
+
snapType: "grid",
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("prefers the edge on a real target over the other edge nearer a ruler line", () => {
|
|
149
|
+
// start 2.99 is 1px from the 3s line; the end 4.94 is 6px from the playhead at 5
|
|
150
|
+
const r = snapMoveToTargets(2.99, 1.95, targets, 100, 60, 0.25);
|
|
151
|
+
expect(r).toEqual({ start: 3.05, snapTime: 5, snapType: "playhead" });
|
|
152
|
+
});
|
|
153
|
+
|
|
123
154
|
it("TIMELINE_SNAP_PX matches the historical beat-snap threshold", () => {
|
|
124
155
|
expect(TIMELINE_SNAP_PX).toBe(8);
|
|
125
156
|
});
|
|
@@ -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
|
});
|