@hyperframes/studio 0.8.58 → 0.8.59
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-zGyOcwwh.js → hyperframes-player-BBub3ZdI.js} +3 -3
- package/dist/assets/{index-ZJaRCDYV.js → index-BxZ5Cw5w.js} +1 -1
- package/dist/assets/{index-BcE2OSVA.js → index-CI7WON-E.js} +1 -1
- package/dist/assets/{index-d8ferjRc.js → index-D1KNPijK.js} +190 -190
- package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
- package/dist/index.d.ts +548 -8
- package/dist/index.html +2 -2
- package/dist/index.js +21596 -20725
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/TimelineToolbar.tsx +19 -0
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
- package/src/components/editor/useColorGradingController.ts +2 -6
- package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
- package/src/components/nle/AudioMeterStrip.tsx +213 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +16 -0
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineCanvas.tsx +10 -56
- package/src/player/components/TimelineClip.tsx +14 -1
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineOverlays.tsx +164 -165
- package/src/player/components/TimelineParts.test.tsx +101 -0
- package/src/player/components/TimelineParts.tsx +74 -0
- package/src/player/components/TimelinePartsParity.test.tsx +58 -0
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/theme.css +4 -0
- package/src/utils/audioMeterMath.test.ts +44 -0
- package/src/utils/audioMeterMath.ts +79 -0
- package/src/utils/audioMeterVisibility.ts +14 -0
- package/src/utils/studioHelpers.ts +21 -0
- package/src/utils/studioUiPreferences.test.ts +10 -0
- package/src/utils/studioUiPreferences.ts +5 -0
- package/src/utils/timelineAssetDrop.test.ts +32 -0
- package/src/utils/timelineAssetDrop.ts +6 -1
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import type { TimelineProps } from "./TimelineTypes";
|
|
3
|
-
import { TimelineEmptyState } from "./TimelineEmptyState";
|
|
4
|
-
import { TimelineCanvas } from "./TimelineCanvas";
|
|
5
|
-
import { TimelineOverlays } from "./TimelineOverlays";
|
|
6
3
|
import { TimelineProvider, useTimelineContext } from "./TimelineProvider";
|
|
4
|
+
import {
|
|
5
|
+
TimelineEmptyStatePart,
|
|
6
|
+
TimelineEditPopover,
|
|
7
|
+
TimelineClipMenu,
|
|
8
|
+
TimelineFrame,
|
|
9
|
+
TimelineGapMenu,
|
|
10
|
+
TimelineKeyframeMenu,
|
|
11
|
+
TimelineLanes,
|
|
12
|
+
TimelineOverlays,
|
|
13
|
+
TimelinePlayhead,
|
|
14
|
+
TimelineRazorGuide,
|
|
15
|
+
TimelineRuler,
|
|
16
|
+
TimelineShortcutHint,
|
|
17
|
+
} from "./TimelineParts";
|
|
7
18
|
|
|
8
19
|
export * from "./TimelineProvider";
|
|
9
20
|
export {
|
|
@@ -28,23 +39,39 @@ function TimelineView() {
|
|
|
28
39
|
const { state, meta } = useTimelineContext();
|
|
29
40
|
const { timelineReady, elements } = state;
|
|
30
41
|
if (!timelineReady || elements.length === 0) {
|
|
31
|
-
return <
|
|
42
|
+
return <TimelineEmptyStatePart />;
|
|
32
43
|
}
|
|
33
44
|
return (
|
|
34
45
|
<div {...meta.containerProps}>
|
|
35
46
|
<div {...meta.viewportProps}>
|
|
36
|
-
<
|
|
37
|
-
|
|
47
|
+
<TimelineFrame />
|
|
48
|
+
<TimelineRazorGuide />
|
|
38
49
|
</div>
|
|
39
50
|
<TimelineOverlays />
|
|
40
51
|
</div>
|
|
41
52
|
);
|
|
42
53
|
}
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
const TimelineComposed = memo(function TimelineComposed(props: TimelineProps = {}) {
|
|
45
56
|
return (
|
|
46
57
|
<TimelineProvider {...props}>
|
|
47
58
|
<TimelineView />
|
|
48
59
|
</TimelineProvider>
|
|
49
60
|
);
|
|
50
61
|
});
|
|
62
|
+
|
|
63
|
+
export const Timeline = Object.assign(TimelineComposed, {
|
|
64
|
+
Provider: TimelineProvider,
|
|
65
|
+
Frame: TimelineFrame,
|
|
66
|
+
Ruler: TimelineRuler,
|
|
67
|
+
Lanes: TimelineLanes,
|
|
68
|
+
Playhead: TimelinePlayhead,
|
|
69
|
+
RazorGuide: TimelineRazorGuide,
|
|
70
|
+
ShortcutHint: TimelineShortcutHint,
|
|
71
|
+
EditPopover: TimelineEditPopover,
|
|
72
|
+
ClipMenu: TimelineClipMenu,
|
|
73
|
+
KeyframeMenu: TimelineKeyframeMenu,
|
|
74
|
+
GapMenu: TimelineGapMenu,
|
|
75
|
+
EmptyState: TimelineEmptyStatePart,
|
|
76
|
+
Overlays: TimelineOverlays,
|
|
77
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { TimelineRulerPart } from "./TimelineRulerPart";
|
|
3
3
|
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
4
4
|
import {
|
|
5
5
|
RULER_H,
|
|
@@ -12,12 +12,8 @@ import {
|
|
|
12
12
|
getTimelineRowTop,
|
|
13
13
|
getTimelineRowHeight,
|
|
14
14
|
} from "./timelineLayout";
|
|
15
|
-
import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
16
|
-
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
17
15
|
import { TimelineLanes } from "./TimelineLanes";
|
|
18
|
-
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
19
16
|
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
|
20
|
-
import { resolveSnapGuide } from "./timelineSnapping";
|
|
21
17
|
import { useTimelineContext } from "./TimelineProvider";
|
|
22
18
|
|
|
23
19
|
// A dropped clip's length is unknown until it lands; the preview shows a default.
|
|
@@ -26,8 +22,7 @@ const DROP_PREVIEW_SECONDS = 3;
|
|
|
26
22
|
export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
27
23
|
const { state, actions } = useTimelineContext();
|
|
28
24
|
const props = state.canvas;
|
|
29
|
-
const { draggedClip,
|
|
30
|
-
const snapGuide = resolveSnapGuide(draggedClip, resizingClip);
|
|
25
|
+
const { draggedClip, scrollRef, displayTrackOrder } = props;
|
|
31
26
|
const draggedRowIndex =
|
|
32
27
|
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
|
33
28
|
const dropTrackIndex = props.dropPreview
|
|
@@ -43,55 +38,14 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
43
38
|
// ghost and drop placeholder must clamp to it or they stretch to the full
|
|
44
39
|
// expanded row height and stop matching the clip being dragged.
|
|
45
40
|
const draggedClipHeight = Math.min(draggedRowHeight, TRACK_H) - CLIP_Y * 2;
|
|
46
|
-
const {
|
|
47
|
-
onResizeElement,
|
|
48
|
-
onMoveElement,
|
|
49
|
-
onToggleTrackHidden,
|
|
50
|
-
onTogglePropertyGroupKeyframe,
|
|
51
|
-
onRazorSplit,
|
|
52
|
-
onRazorSplitAll,
|
|
53
|
-
} = useTimelineEditContextOptional();
|
|
54
41
|
const beatDragging = props.beatDragging;
|
|
55
|
-
const draggedElement
|
|
56
|
-
const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null;
|
|
57
|
-
// The drag ghost follows the cursor freely (both axes) — CapCut-style. The
|
|
58
|
-
// "magnetic" affordance is a highlight on the destination lane (draggedRowIndex),
|
|
59
|
-
// which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it.
|
|
60
|
-
// Live multi-selection drag: while a selected clip is dragged, ALL selected
|
|
61
|
-
// clips move together as one rigid formation. The GRABBED clip is the free
|
|
62
|
-
// ghost below; its co-selected "passengers" slide by the SAME group-clamped
|
|
63
|
-
// delta (cheap translateX, no re-layout) — the delta is derived from the
|
|
64
|
-
// grabbed clip's ALREADY-clamped previewStart, so the whole formation stops at
|
|
65
|
-
// the wall together and never deforms. Matches what the commit will do — see
|
|
66
|
-
// timelineMultiDragPreview + commit.
|
|
67
|
-
const multiDragPreview: MultiDragPreviewInput | null =
|
|
68
|
-
draggedClip?.started === true && draggedElement && draggedElementIdentity
|
|
69
|
-
? {
|
|
70
|
-
dragStarted: true,
|
|
71
|
-
draggedKey: draggedElementIdentity,
|
|
72
|
-
draggedOriginStart: draggedElement.start,
|
|
73
|
-
draggedPreviewStart: draggedClip.previewStart,
|
|
74
|
-
selectedKeys: selectedElementIds,
|
|
75
|
-
}
|
|
76
|
-
: null;
|
|
42
|
+
const { draggedElement, snapGuide, multiDragPreview } = props;
|
|
77
43
|
return (
|
|
78
44
|
<div
|
|
79
45
|
className="relative"
|
|
80
46
|
style={{ height: props.totalH, width: props.contentOrigin + props.trackContentWidth }}
|
|
81
47
|
>
|
|
82
|
-
<
|
|
83
|
-
major={props.major}
|
|
84
|
-
minor={props.minor}
|
|
85
|
-
pps={props.pps}
|
|
86
|
-
trackContentWidth={props.trackContentWidth}
|
|
87
|
-
totalH={props.totalH}
|
|
88
|
-
effectiveDuration={props.effectiveDuration}
|
|
89
|
-
majorTickInterval={props.majorTickInterval}
|
|
90
|
-
theme={props.theme}
|
|
91
|
-
beatAnalysis={props.beatAnalysis}
|
|
92
|
-
contentOrigin={props.contentOrigin}
|
|
93
|
-
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
|
|
94
|
-
/>
|
|
48
|
+
<TimelineRulerPart />
|
|
95
49
|
|
|
96
50
|
{/* Breathing room between the sticky ruler and the first track lane — the
|
|
97
51
|
top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
|
|
@@ -104,12 +58,12 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
104
58
|
snapGuide={snapGuide}
|
|
105
59
|
draggedElement={draggedElement}
|
|
106
60
|
multiDragPreview={multiDragPreview}
|
|
107
|
-
onToggleTrackHidden={onToggleTrackHidden}
|
|
108
|
-
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
|
109
|
-
onResizeElement={onResizeElement}
|
|
110
|
-
onMoveElement={onMoveElement}
|
|
111
|
-
onRazorSplit={onRazorSplit}
|
|
112
|
-
onRazorSplitAll={onRazorSplitAll}
|
|
61
|
+
onToggleTrackHidden={props.onToggleTrackHidden}
|
|
62
|
+
onTogglePropertyGroupKeyframe={props.onTogglePropertyGroupKeyframe}
|
|
63
|
+
onResizeElement={props.onResizeElement}
|
|
64
|
+
onMoveElement={props.onMoveElement}
|
|
65
|
+
onRazorSplit={props.onRazorSplit}
|
|
66
|
+
onRazorSplitAll={props.onRazorSplitAll}
|
|
113
67
|
/>
|
|
114
68
|
|
|
115
69
|
{/* Breathing room below the last track lane (~1.5 track heights) — a real
|
|
@@ -4,6 +4,7 @@ import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "
|
|
|
4
4
|
import type { TimelineEditCapabilities } from "./timelineEditing";
|
|
5
5
|
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
6
6
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
7
|
+
import { TimelineClipFades } from "./TimelineClipFades";
|
|
7
8
|
|
|
8
9
|
interface TimelineClipProps {
|
|
9
10
|
el: TimelineElement;
|
|
@@ -74,6 +75,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
74
75
|
"--clip-border-active": theme.clipBorderActive,
|
|
75
76
|
"--clip-handle": theme.handleColor,
|
|
76
77
|
} as CSSProperties;
|
|
78
|
+
const isAudioClip = isAudioTimelineElement(el);
|
|
77
79
|
const clipClassName = [
|
|
78
80
|
"timeline-clip",
|
|
79
81
|
"absolute",
|
|
@@ -81,7 +83,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
81
83
|
isSelected ? "is-selected" : "",
|
|
82
84
|
isHovered ? "is-hovered" : "",
|
|
83
85
|
isDragging ? "is-dragging" : "",
|
|
84
|
-
|
|
86
|
+
isAudioClip ? "is-audio" : "",
|
|
85
87
|
]
|
|
86
88
|
.filter((className) => className.length > 0)
|
|
87
89
|
.join(" ");
|
|
@@ -198,6 +200,17 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
198
200
|
</span>
|
|
199
201
|
)}
|
|
200
202
|
{children}
|
|
203
|
+
{/* Fade handles + ramps for anything the mixer hears — audio clips and
|
|
204
|
+
videos marked data-has-audio. They write data-fade-in/out on the clip
|
|
205
|
+
and are the timeline half of the inspector's Fade rows. */}
|
|
206
|
+
{(isAudioClip || el.hasAudio) && !isGestureActor && (
|
|
207
|
+
<TimelineClipFades
|
|
208
|
+
el={el}
|
|
209
|
+
pps={pps}
|
|
210
|
+
widthPx={widthPx}
|
|
211
|
+
showHandles={(isHovered || isSelected) && !isDragging}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
201
214
|
</button>
|
|
202
215
|
);
|
|
203
216
|
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { TimelineClipFades } from "./TimelineClipFades";
|
|
8
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const clip: TimelineElement = {
|
|
17
|
+
id: "music",
|
|
18
|
+
tag: "audio",
|
|
19
|
+
src: "assets/music.wav",
|
|
20
|
+
start: 2,
|
|
21
|
+
duration: 10,
|
|
22
|
+
track: 1,
|
|
23
|
+
fadeIn: 1,
|
|
24
|
+
fadeOut: 2,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function render(
|
|
28
|
+
el: TimelineElement,
|
|
29
|
+
options: { showHandles?: boolean; provide?: boolean; onClipPointerDown?: () => void } = {},
|
|
30
|
+
) {
|
|
31
|
+
const host = document.createElement("div");
|
|
32
|
+
document.body.append(host);
|
|
33
|
+
const root = createRoot(host);
|
|
34
|
+
const onSetElementAttributeLive = vi.fn();
|
|
35
|
+
const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined);
|
|
36
|
+
// Stands in for the TimelineClip button the handles live inside.
|
|
37
|
+
const node = (
|
|
38
|
+
<div data-testid="clip" onPointerDown={options.onClipPointerDown}>
|
|
39
|
+
<TimelineClipFades
|
|
40
|
+
el={el}
|
|
41
|
+
pps={100}
|
|
42
|
+
widthPx={el.duration * 100}
|
|
43
|
+
showHandles={options.showHandles ?? true}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
act(() => {
|
|
48
|
+
root.render(
|
|
49
|
+
options.provide === false ? (
|
|
50
|
+
node
|
|
51
|
+
) : (
|
|
52
|
+
<TimelineEditProvider value={{ onSetElementAttributeLive, onSetElementAttributeQuiet }}>
|
|
53
|
+
{node}
|
|
54
|
+
</TimelineEditProvider>
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
return { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pointer(type: string, clientX: number, pointerId = 1) {
|
|
62
|
+
const event = new MouseEvent(type, { bubbles: true, clientX, button: 0 });
|
|
63
|
+
Object.defineProperty(event, "pointerId", { value: pointerId });
|
|
64
|
+
return event;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function armCapture(handle: HTMLElement) {
|
|
68
|
+
let captured = false;
|
|
69
|
+
Object.defineProperty(handle, "setPointerCapture", { value: () => (captured = true) });
|
|
70
|
+
Object.defineProperty(handle, "releasePointerCapture", { value: () => (captured = false) });
|
|
71
|
+
Object.defineProperty(handle, "hasPointerCapture", { value: () => captured });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("TimelineClipFades", () => {
|
|
75
|
+
it("shades the fade-in and fade-out as ramps sized by pps", () => {
|
|
76
|
+
const { host, root } = render(clip, { showHandles: false, provide: false });
|
|
77
|
+
const fadeIn = host.querySelector('[data-testid="clip-fade-in"]');
|
|
78
|
+
const fadeOut = host.querySelector('[data-testid="clip-fade-out"]');
|
|
79
|
+
// 1 s in at 100 px/s: the wedge spans 0..100; 2 s out on a 1000 px clip: 800..1000.
|
|
80
|
+
expect(fadeIn?.getAttribute("points")).toBe("0,0 100,0 0,100");
|
|
81
|
+
expect(fadeOut?.getAttribute("points")).toBe("800,0 1000,0 1000,100");
|
|
82
|
+
// Outside a provider there is nothing to write to, so no handles either.
|
|
83
|
+
expect(host.querySelector('[data-testid="clip-fade-handle-in"]')).toBeNull();
|
|
84
|
+
act(() => root.unmount());
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("renders nothing for a clip without fades when the handles are hidden", () => {
|
|
88
|
+
const { host, root } = render(
|
|
89
|
+
{ ...clip, fadeIn: undefined, fadeOut: undefined },
|
|
90
|
+
{
|
|
91
|
+
showHandles: false,
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
expect(host.querySelector('[data-testid="clip"]')?.innerHTML).toBe("");
|
|
95
|
+
act(() => root.unmount());
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("drags the fade-in dot to the right, previewing live and committing once on release", () => {
|
|
99
|
+
const { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip);
|
|
100
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
101
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
102
|
+
armCapture(handle);
|
|
103
|
+
// The handle sits at the 1 s mark (x=100). Drag 150 px right → 2.5 s.
|
|
104
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
105
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 200)));
|
|
106
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 250)));
|
|
107
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "2.5");
|
|
108
|
+
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
|
109
|
+
// The ramp follows the pointer before anything is persisted.
|
|
110
|
+
expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
|
|
111
|
+
"0,0 250,0 0,100",
|
|
112
|
+
);
|
|
113
|
+
act(() => handle.dispatchEvent(pointer("pointerup", 250)));
|
|
114
|
+
expect(onSetElementAttributeQuiet).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", "2.5", "Fade in");
|
|
116
|
+
act(() => root.unmount());
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("drags the fade-out dot to the left, growing the fade, and never past the fade-in", () => {
|
|
120
|
+
const { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip);
|
|
121
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
122
|
+
if (!handle) throw new Error("expected a fade-out handle");
|
|
123
|
+
armCapture(handle);
|
|
124
|
+
// Handle at x=800 (2 s before the end). 300 px left → 5 s.
|
|
125
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 800)));
|
|
126
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 500)));
|
|
127
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
|
|
128
|
+
// Way past the start: clamps to duration − fadeIn = 9 s.
|
|
129
|
+
act(() => handle.dispatchEvent(pointer("pointermove", -500)));
|
|
130
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "9");
|
|
131
|
+
act(() => handle.dispatchEvent(pointer("pointerup", -500)));
|
|
132
|
+
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-out", "9", "Fade out");
|
|
133
|
+
act(() => root.unmount());
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("removes the attribute when dragged back to zero", () => {
|
|
137
|
+
const { host, root, onSetElementAttributeQuiet } = render(clip);
|
|
138
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
139
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
140
|
+
armCapture(handle);
|
|
141
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
142
|
+
act(() => handle.dispatchEvent(pointer("pointermove", -40)));
|
|
143
|
+
act(() => handle.dispatchEvent(pointer("pointerup", -40)));
|
|
144
|
+
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", null, "Fade in");
|
|
145
|
+
act(() => root.unmount());
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("puts the live value back and writes nothing when the gesture is cancelled", () => {
|
|
149
|
+
const { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip);
|
|
150
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
151
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
152
|
+
armCapture(handle);
|
|
153
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
154
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 300)));
|
|
155
|
+
act(() => handle.dispatchEvent(pointer("pointercancel", 300)));
|
|
156
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
|
|
157
|
+
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
|
158
|
+
act(() => root.unmount());
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("does not let a press on the dot start the clip's own move gesture", () => {
|
|
162
|
+
const outer = vi.fn();
|
|
163
|
+
const { host, root } = render(clip, { onClipPointerDown: outer });
|
|
164
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
165
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
166
|
+
armCapture(handle);
|
|
167
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
168
|
+
expect(outer).not.toHaveBeenCalled();
|
|
169
|
+
act(() => root.unmount());
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type KeyboardEvent,
|
|
7
|
+
type PointerEvent,
|
|
8
|
+
} from "react";
|
|
9
|
+
import {
|
|
10
|
+
HF_AUDIO_FADE_IN_ATTR,
|
|
11
|
+
HF_AUDIO_FADE_OUT_ATTR,
|
|
12
|
+
clampFadesToDuration,
|
|
13
|
+
formatFadeSeconds,
|
|
14
|
+
} from "@hyperframes/core/audio-fade";
|
|
15
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
16
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
17
|
+
|
|
18
|
+
type FadeEdge = "in" | "out";
|
|
19
|
+
|
|
20
|
+
const HANDLE_SIZE = 10;
|
|
21
|
+
const HANDLE_HIT = 16;
|
|
22
|
+
/** Pixels of pointer travel before a press on the handle counts as a drag. */
|
|
23
|
+
const DRAG_THRESHOLD_PX = 2;
|
|
24
|
+
|
|
25
|
+
interface TimelineClipFadesProps {
|
|
26
|
+
el: TimelineElement;
|
|
27
|
+
pps: number;
|
|
28
|
+
widthPx: number;
|
|
29
|
+
/** Handles show on hover/selection; the shaded ramps show whenever a fade is set. */
|
|
30
|
+
showHandles: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Corner dots that drag `data-fade-in` / `data-fade-out`; the wedge is the faded region. */
|
|
34
|
+
export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineClipFadesProps) {
|
|
35
|
+
const { onSetElementAttributeLive, onSetElementAttributeQuiet } =
|
|
36
|
+
useTimelineEditContextOptional();
|
|
37
|
+
const canEdit = Boolean(onSetElementAttributeLive && onSetElementAttributeQuiet);
|
|
38
|
+
|
|
39
|
+
// Draft holds the value under the pointer during a drag; it also bridges
|
|
40
|
+
// the gap between release and the store re-reading the file, so the ramp
|
|
41
|
+
// does not snap back for a frame.
|
|
42
|
+
const [draft, setDraft] = useState<{ edge: FadeEdge; seconds: number } | null>(null);
|
|
43
|
+
const [dragging, setDragging] = useState<FadeEdge | null>(null);
|
|
44
|
+
const authoredIn = el.fadeIn ?? 0;
|
|
45
|
+
const authoredOut = el.fadeOut ?? 0;
|
|
46
|
+
useEffect(() => setDraft(null), [authoredIn, authoredOut]);
|
|
47
|
+
|
|
48
|
+
const fades = clampFadesToDuration(
|
|
49
|
+
{
|
|
50
|
+
fadeIn: draft?.edge === "in" ? draft.seconds : authoredIn,
|
|
51
|
+
fadeOut: draft?.edge === "out" ? draft.seconds : authoredOut,
|
|
52
|
+
},
|
|
53
|
+
el.duration,
|
|
54
|
+
);
|
|
55
|
+
const inPx = Math.min(widthPx, fades.fadeIn * pps);
|
|
56
|
+
const outPx = Math.min(widthPx, fades.fadeOut * pps);
|
|
57
|
+
|
|
58
|
+
const gesture = useRef<{
|
|
59
|
+
edge: FadeEdge;
|
|
60
|
+
pointerId: number;
|
|
61
|
+
originClientX: number;
|
|
62
|
+
originSeconds: number;
|
|
63
|
+
otherSeconds: number;
|
|
64
|
+
moved: boolean;
|
|
65
|
+
last: number;
|
|
66
|
+
} | null>(null);
|
|
67
|
+
|
|
68
|
+
const attrFor = (edge: FadeEdge) =>
|
|
69
|
+
edge === "in" ? HF_AUDIO_FADE_IN_ATTR : HF_AUDIO_FADE_OUT_ATTR;
|
|
70
|
+
const attrText = (seconds: number) => (seconds > 0 ? formatFadeSeconds(seconds) : null);
|
|
71
|
+
|
|
72
|
+
const secondsAt = (clientX: number): number => {
|
|
73
|
+
const g = gesture.current;
|
|
74
|
+
if (!g) return 0;
|
|
75
|
+
const deltaSeconds = (clientX - g.originClientX) / Math.max(pps, 1e-6);
|
|
76
|
+
// Fade-in grows to the right, fade-out grows to the left.
|
|
77
|
+
const raw = g.edge === "in" ? g.originSeconds + deltaSeconds : g.originSeconds - deltaSeconds;
|
|
78
|
+
const limit = Math.max(0, el.duration - g.otherSeconds);
|
|
79
|
+
const clamped = Math.min(limit, Math.max(0, raw));
|
|
80
|
+
return Math.round(clamped * 100) / 100;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const onHandlePointerDown = (edge: FadeEdge) => (e: PointerEvent<HTMLDivElement>) => {
|
|
84
|
+
if (e.button !== 0 || !canEdit) return;
|
|
85
|
+
// Ours, not the clip's: a press here must not start a move or trim.
|
|
86
|
+
e.stopPropagation();
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
89
|
+
gesture.current = {
|
|
90
|
+
edge,
|
|
91
|
+
pointerId: e.pointerId,
|
|
92
|
+
originClientX: e.clientX,
|
|
93
|
+
originSeconds: edge === "in" ? authoredIn : authoredOut,
|
|
94
|
+
otherSeconds: edge === "in" ? authoredOut : authoredIn,
|
|
95
|
+
moved: false,
|
|
96
|
+
last: edge === "in" ? authoredIn : authoredOut,
|
|
97
|
+
};
|
|
98
|
+
setDragging(edge);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const onHandlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
|
102
|
+
const g = gesture.current;
|
|
103
|
+
if (!g || g.pointerId !== e.pointerId) return;
|
|
104
|
+
if (!g.moved && Math.abs(e.clientX - g.originClientX) < DRAG_THRESHOLD_PX) return;
|
|
105
|
+
g.moved = true;
|
|
106
|
+
const seconds = secondsAt(e.clientX);
|
|
107
|
+
if (seconds === g.last) return;
|
|
108
|
+
g.last = seconds;
|
|
109
|
+
setDraft({ edge: g.edge, seconds });
|
|
110
|
+
onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(seconds));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
type Gesture = NonNullable<typeof gesture.current>;
|
|
114
|
+
|
|
115
|
+
/** Ends the pointer gesture and returns it, or null when the event is not ours. */
|
|
116
|
+
const endGesture = (e: PointerEvent<HTMLDivElement>): Gesture | null => {
|
|
117
|
+
const g = gesture.current;
|
|
118
|
+
if (!g || g.pointerId !== e.pointerId) return null;
|
|
119
|
+
gesture.current = null;
|
|
120
|
+
setDragging(null);
|
|
121
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
122
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
123
|
+
}
|
|
124
|
+
return g;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Puts the live document back where the file has it and drops the draft. */
|
|
128
|
+
const revertGesture = (g: Gesture) => {
|
|
129
|
+
if (g.moved) onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds));
|
|
130
|
+
setDraft(null);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const finish = (e: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
|
|
134
|
+
const g = endGesture(e);
|
|
135
|
+
if (!g) return;
|
|
136
|
+
if (cancelled || !g.moved) return revertGesture(g);
|
|
137
|
+
setDraft({ edge: g.edge, seconds: g.last });
|
|
138
|
+
void onSetElementAttributeQuiet?.(
|
|
139
|
+
el,
|
|
140
|
+
attrFor(g.edge),
|
|
141
|
+
attrText(g.last),
|
|
142
|
+
g.edge === "in" ? "Fade in" : "Fade out",
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const onHandleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
147
|
+
const g = gesture.current;
|
|
148
|
+
if (e.key !== "Escape" || !g) return;
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
gesture.current = null;
|
|
151
|
+
setDragging(null);
|
|
152
|
+
revertGesture(g);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const showIn = fades.fadeIn > 0;
|
|
156
|
+
const showOut = fades.fadeOut > 0;
|
|
157
|
+
const handlesVisible = showHandles || dragging !== null;
|
|
158
|
+
if (!showIn && !showOut && !handlesVisible) return null;
|
|
159
|
+
|
|
160
|
+
const handleStyle = (leftPx: number): CSSProperties => ({
|
|
161
|
+
position: "absolute",
|
|
162
|
+
top: -(HANDLE_HIT - HANDLE_SIZE) / 2 + 1,
|
|
163
|
+
left: leftPx - HANDLE_HIT / 2,
|
|
164
|
+
width: HANDLE_HIT,
|
|
165
|
+
height: HANDLE_HIT,
|
|
166
|
+
display: "flex",
|
|
167
|
+
alignItems: "center",
|
|
168
|
+
justifyContent: "center",
|
|
169
|
+
cursor: "ew-resize",
|
|
170
|
+
zIndex: 6,
|
|
171
|
+
opacity: handlesVisible ? 1 : 0,
|
|
172
|
+
pointerEvents: handlesVisible && canEdit ? "auto" : "none",
|
|
173
|
+
touchAction: "none",
|
|
174
|
+
outline: "none",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<>
|
|
179
|
+
{(showIn || showOut) && (
|
|
180
|
+
<svg
|
|
181
|
+
aria-hidden="true"
|
|
182
|
+
data-testid="clip-fade-ramps"
|
|
183
|
+
style={{
|
|
184
|
+
position: "absolute",
|
|
185
|
+
inset: 0,
|
|
186
|
+
width: "100%",
|
|
187
|
+
height: "100%",
|
|
188
|
+
pointerEvents: "none",
|
|
189
|
+
zIndex: 5,
|
|
190
|
+
overflow: "visible",
|
|
191
|
+
}}
|
|
192
|
+
viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
|
|
193
|
+
preserveAspectRatio="none"
|
|
194
|
+
>
|
|
195
|
+
{/* The shaded wedge is the gain that is NOT there: above the ramp. */}
|
|
196
|
+
{showIn && (
|
|
197
|
+
<>
|
|
198
|
+
<polygon
|
|
199
|
+
data-testid="clip-fade-in"
|
|
200
|
+
points={`0,0 ${inPx},0 0,100`}
|
|
201
|
+
fill="var(--timeline-fade-shade)"
|
|
202
|
+
/>
|
|
203
|
+
<line
|
|
204
|
+
x1={0}
|
|
205
|
+
y1={100}
|
|
206
|
+
x2={inPx}
|
|
207
|
+
y2={0}
|
|
208
|
+
stroke="var(--clip-handle)"
|
|
209
|
+
strokeOpacity={0.9}
|
|
210
|
+
strokeWidth={1.25}
|
|
211
|
+
vectorEffect="non-scaling-stroke"
|
|
212
|
+
/>
|
|
213
|
+
</>
|
|
214
|
+
)}
|
|
215
|
+
{showOut && (
|
|
216
|
+
<>
|
|
217
|
+
<polygon
|
|
218
|
+
data-testid="clip-fade-out"
|
|
219
|
+
points={`${widthPx - outPx},0 ${widthPx},0 ${widthPx},100`}
|
|
220
|
+
fill="var(--timeline-fade-shade)"
|
|
221
|
+
/>
|
|
222
|
+
<line
|
|
223
|
+
x1={widthPx - outPx}
|
|
224
|
+
y1={0}
|
|
225
|
+
x2={widthPx}
|
|
226
|
+
y2={100}
|
|
227
|
+
stroke="var(--clip-handle)"
|
|
228
|
+
strokeOpacity={0.9}
|
|
229
|
+
strokeWidth={1.25}
|
|
230
|
+
vectorEffect="non-scaling-stroke"
|
|
231
|
+
/>
|
|
232
|
+
</>
|
|
233
|
+
)}
|
|
234
|
+
</svg>
|
|
235
|
+
)}
|
|
236
|
+
{canEdit && (
|
|
237
|
+
<>
|
|
238
|
+
<div
|
|
239
|
+
role="slider"
|
|
240
|
+
tabIndex={-1}
|
|
241
|
+
aria-label="Fade in"
|
|
242
|
+
aria-valuemin={0}
|
|
243
|
+
aria-valuemax={Math.max(0, el.duration - fades.fadeOut)}
|
|
244
|
+
aria-valuenow={fades.fadeIn}
|
|
245
|
+
aria-valuetext={`${formatFadeSeconds(fades.fadeIn)}s`}
|
|
246
|
+
data-testid="clip-fade-handle-in"
|
|
247
|
+
title={`Fade in: ${formatFadeSeconds(fades.fadeIn)}s — drag to change`}
|
|
248
|
+
style={handleStyle(inPx)}
|
|
249
|
+
onPointerDown={onHandlePointerDown("in")}
|
|
250
|
+
onPointerMove={onHandlePointerMove}
|
|
251
|
+
onPointerUp={(e) => finish(e, false)}
|
|
252
|
+
onPointerCancel={(e) => finish(e, true)}
|
|
253
|
+
onKeyDown={onHandleKeyDown}
|
|
254
|
+
onClick={(e) => e.stopPropagation()}
|
|
255
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
256
|
+
>
|
|
257
|
+
<FadeDot active={dragging === "in"} />
|
|
258
|
+
</div>
|
|
259
|
+
<div
|
|
260
|
+
role="slider"
|
|
261
|
+
tabIndex={-1}
|
|
262
|
+
aria-label="Fade out"
|
|
263
|
+
aria-valuemin={0}
|
|
264
|
+
aria-valuemax={Math.max(0, el.duration - fades.fadeIn)}
|
|
265
|
+
aria-valuenow={fades.fadeOut}
|
|
266
|
+
aria-valuetext={`${formatFadeSeconds(fades.fadeOut)}s`}
|
|
267
|
+
data-testid="clip-fade-handle-out"
|
|
268
|
+
title={`Fade out: ${formatFadeSeconds(fades.fadeOut)}s — drag to change`}
|
|
269
|
+
style={handleStyle(widthPx - outPx)}
|
|
270
|
+
onPointerDown={onHandlePointerDown("out")}
|
|
271
|
+
onPointerMove={onHandlePointerMove}
|
|
272
|
+
onPointerUp={(e) => finish(e, false)}
|
|
273
|
+
onPointerCancel={(e) => finish(e, true)}
|
|
274
|
+
onKeyDown={onHandleKeyDown}
|
|
275
|
+
onClick={(e) => e.stopPropagation()}
|
|
276
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
277
|
+
>
|
|
278
|
+
<FadeDot active={dragging === "out"} />
|
|
279
|
+
</div>
|
|
280
|
+
</>
|
|
281
|
+
)}
|
|
282
|
+
</>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function FadeDot({ active }: { active: boolean }) {
|
|
287
|
+
return (
|
|
288
|
+
<span
|
|
289
|
+
aria-hidden="true"
|
|
290
|
+
style={{
|
|
291
|
+
display: "block",
|
|
292
|
+
width: HANDLE_SIZE,
|
|
293
|
+
height: HANDLE_SIZE,
|
|
294
|
+
borderRadius: "50%",
|
|
295
|
+
background: active ? "var(--timeline-fade-dot-active)" : "var(--clip-handle)",
|
|
296
|
+
boxShadow: "0 0 0 1.5px var(--timeline-fade-dot-ring)",
|
|
297
|
+
transform: active ? "scale(1.15)" : undefined,
|
|
298
|
+
transition: "transform 80ms ease-out",
|
|
299
|
+
}}
|
|
300
|
+
/>
|
|
301
|
+
);
|
|
302
|
+
}
|