@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.
Files changed (52) hide show
  1. package/dist/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-BBub3ZdI.js} +3 -3
  2. package/dist/assets/{index-ZJaRCDYV.js → index-BxZ5Cw5w.js} +1 -1
  3. package/dist/assets/{index-BcE2OSVA.js → index-CI7WON-E.js} +1 -1
  4. package/dist/assets/{index-d8ferjRc.js → index-D1KNPijK.js} +190 -190
  5. package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  6. package/dist/index.d.ts +548 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21596 -20725
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/components/TimelineToolbar.tsx +19 -0
  13. package/src/components/editor/audioInspectorInput.test.ts +55 -0
  14. package/src/components/editor/audioInspectorInput.ts +55 -0
  15. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
  16. package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
  17. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
  18. package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
  19. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
  20. package/src/components/editor/useColorGradingController.ts +2 -6
  21. package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
  22. package/src/components/nle/AudioMeterStrip.tsx +213 -0
  23. package/src/components/nle/TimelinePane.tsx +29 -23
  24. package/src/contexts/TimelineEditContext.tsx +4 -0
  25. package/src/hooks/useTimelineAssetDropOps.ts +9 -1
  26. package/src/index.ts +16 -0
  27. package/src/player/components/Timeline.test.ts +22 -31
  28. package/src/player/components/Timeline.tsx +34 -7
  29. package/src/player/components/TimelineCanvas.tsx +10 -56
  30. package/src/player/components/TimelineClip.tsx +14 -1
  31. package/src/player/components/TimelineClipFades.test.tsx +171 -0
  32. package/src/player/components/TimelineClipFades.tsx +302 -0
  33. package/src/player/components/TimelineOverlays.tsx +164 -165
  34. package/src/player/components/TimelineParts.test.tsx +101 -0
  35. package/src/player/components/TimelineParts.tsx +74 -0
  36. package/src/player/components/TimelinePartsParity.test.tsx +58 -0
  37. package/src/player/components/TimelineProvider.tsx +25 -0
  38. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  39. package/src/player/components/TimelineRulerPart.tsx +22 -0
  40. package/src/player/components/timelineProviderStateBuilders.ts +20 -0
  41. package/src/player/components/useTimelineProviderState.tsx +19 -12
  42. package/src/player/lib/timelineElementHelpers.ts +20 -0
  43. package/src/player/store/timelineElement.ts +5 -0
  44. package/src/styles/theme.css +4 -0
  45. package/src/utils/audioMeterMath.test.ts +44 -0
  46. package/src/utils/audioMeterMath.ts +79 -0
  47. package/src/utils/audioMeterVisibility.ts +14 -0
  48. package/src/utils/studioHelpers.ts +21 -0
  49. package/src/utils/studioUiPreferences.test.ts +10 -0
  50. package/src/utils/studioUiPreferences.ts +5 -0
  51. package/src/utils/timelineAssetDrop.test.ts +32 -0
  52. 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 <TimelineEmptyState {...meta.emptyState} />;
42
+ return <TimelineEmptyStatePart />;
32
43
  }
33
44
  return (
34
45
  <div {...meta.containerProps}>
35
46
  <div {...meta.viewportProps}>
36
- <TimelineCanvas />
37
- {meta.razorGuide}
47
+ <TimelineFrame />
48
+ <TimelineRazorGuide />
38
49
  </div>
39
50
  <TimelineOverlays />
40
51
  </div>
41
52
  );
42
53
  }
43
54
 
44
- export const Timeline = memo(function Timeline(props: TimelineProps = {}) {
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 { TimelineRuler } from "./TimelineRuler";
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, resizingClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
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 = draggedClip?.element ?? null;
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
- <TimelineRuler
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
- isAudioTimelineElement(el) ? "is-audio" : "",
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
+ }