@hyperframes/studio 0.8.57 → 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 (68) hide show
  1. package/dist/assets/{hyperframes-player-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
  2. package/dist/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
  3. package/dist/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
  4. package/dist/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
  5. package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  6. package/dist/index.d.ts +577 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +22537 -21600
  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/MotionPathOverlay.test.tsx +0 -1
  14. package/src/components/editor/audioInspectorInput.test.ts +55 -0
  15. package/src/components/editor/audioInspectorInput.ts +55 -0
  16. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
  17. package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
  18. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
  19. package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
  20. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
  21. package/src/components/editor/useColorGradingController.ts +2 -6
  22. package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
  23. package/src/components/nle/AudioMeterStrip.tsx +213 -0
  24. package/src/components/nle/NLEContext.tsx +8 -2
  25. package/src/components/nle/NLEPreview.test.ts +1 -0
  26. package/src/components/nle/TimelinePane.tsx +29 -23
  27. package/src/components/nle/useCompositionStack.ts +1 -1
  28. package/src/contexts/TimelineEditContext.tsx +4 -0
  29. package/src/hooks/useCompositionDimensions.test.tsx +0 -1
  30. package/src/hooks/useSdkSession.lifecycle.test.tsx +28 -0
  31. package/src/hooks/useSdkSession.ts +31 -4
  32. package/src/hooks/useTimelineAssetDropOps.ts +9 -1
  33. package/src/index.ts +25 -1
  34. package/src/player/components/LayerDisclosureRow.tsx +3 -3
  35. package/src/player/components/Player.test.ts +34 -0
  36. package/src/player/components/Timeline.test.ts +22 -31
  37. package/src/player/components/Timeline.tsx +34 -7
  38. package/src/player/components/TimelineCanvas.tsx +10 -56
  39. package/src/player/components/TimelineClip.tsx +14 -1
  40. package/src/player/components/TimelineClipFades.test.tsx +171 -0
  41. package/src/player/components/TimelineClipFades.tsx +302 -0
  42. package/src/player/components/TimelineGroupHeader.tsx +4 -6
  43. package/src/player/components/TimelineOverlays.tsx +164 -165
  44. package/src/player/components/TimelineParts.test.tsx +101 -0
  45. package/src/player/components/TimelineParts.tsx +74 -0
  46. package/src/player/components/TimelinePartsParity.test.tsx +58 -0
  47. package/src/player/components/TimelineProvider.tsx +25 -0
  48. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  49. package/src/player/components/TimelineRulerPart.tsx +22 -0
  50. package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
  51. package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
  52. package/src/player/components/timelineProviderStateBuilders.ts +20 -0
  53. package/src/player/components/useTimelineProviderState.tsx +19 -12
  54. package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
  55. package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
  56. package/src/player/hooks/usePlayerHandle.ts +94 -0
  57. package/src/player/index.ts +7 -0
  58. package/src/player/lib/timelineElementHelpers.ts +20 -0
  59. package/src/player/store/timelineElement.ts +5 -0
  60. package/src/styles/theme.css +4 -0
  61. package/src/utils/audioMeterMath.test.ts +44 -0
  62. package/src/utils/audioMeterMath.ts +79 -0
  63. package/src/utils/audioMeterVisibility.ts +14 -0
  64. package/src/utils/studioHelpers.ts +21 -0
  65. package/src/utils/studioUiPreferences.test.ts +10 -0
  66. package/src/utils/studioUiPreferences.ts +5 -0
  67. package/src/utils/timelineAssetDrop.test.ts +32 -0
  68. package/src/utils/timelineAssetDrop.ts +6 -1
@@ -16,12 +16,23 @@ import { addExternalFileReloadListener } from "./externalFileReloadBus";
16
16
  *
17
17
  * Every reason lives in this union so the full surface is readable from one
18
18
  * place — `absent_or_empty` included, even though it is a 2xx.
19
+ *
20
+ * `network` is a fetch that REJECTED — the request never produced a response.
21
+ * It was not in this union until 2026-09-21, so it escaped `readProjectFileOptional`
22
+ * and was caught by the effect's outer `.catch`, landing in `stage: "open"`.
23
+ * That label means `openComposition` threw (unparseable composition, OOM), and
24
+ * it is what the largest failure class was reported as. Every `stage: open`
25
+ * event measured on 0.8.56 and 0.8.57 carries a fetch-rejection message —
26
+ * "Failed to fetch", "Load failed", "NetworkError when attempting to fetch
27
+ * resource." — so the class was a network problem filed under a parser one, and
28
+ * unaddressable in that bucket.
19
29
  */
20
30
  type ProjectFileReadFailure =
21
31
  | { ok: false; reason: "unsafe_path" }
22
32
  | { ok: false; reason: "http_error"; status: number }
23
33
  | { ok: false; reason: "missing_content" }
24
- | { ok: false; reason: "absent_or_empty" };
34
+ | { ok: false; reason: "absent_or_empty" }
35
+ | { ok: false; reason: "network" };
25
36
 
26
37
  type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
27
38
 
@@ -68,9 +79,20 @@ async function readProjectFileOptional(
68
79
  // and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
69
80
  // already confines both values to single segments of this same-origin URL.
70
81
  if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
71
- const res = await fetch(
72
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
73
- );
82
+ let res: Response;
83
+ try {
84
+ res = await fetch(
85
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
86
+ );
87
+ } catch {
88
+ // The request never produced a response: offline, the dev server gone, a
89
+ // CSP/Private-Network-Access block, or an extension rewriting fetch. Caught
90
+ // here so it is reported as a READ failure — left to propagate, the effect's
91
+ // outer catch reported it as `stage: "open"`, which claims the composition
92
+ // failed to parse. Every `stage: open` event before this change was this
93
+ // branch, so the label made the largest class unaddressable.
94
+ return { ok: false, reason: "network" };
95
+ }
74
96
  if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
75
97
  const data = (await res.json()) as { content?: string };
76
98
  // `optional=1` answers a missing file with 200 + `content: ""`, so a
@@ -299,6 +321,11 @@ export function useSdkSession(
299
321
  // openComposition threw (unparseable composition, OOM) — same total
300
322
  // bypass as the read failure above, but this one is a real defect
301
323
  // rather than a missing file. Carry the message; it is the only clue.
324
+ //
325
+ // A rejected read no longer reaches here: `readProjectFileOptional`
326
+ // catches its own fetch rejection and answers `reason: "network"`, so
327
+ // this stage now means what it says. Before that, every event in this
328
+ // bucket was a network error wearing a parser's label.
302
329
  trackStudioEvent("sdk_session_unavailable", {
303
330
  stage: "open",
304
331
  error: error instanceof Error ? error.message : String(error),
@@ -18,7 +18,11 @@ import {
18
18
  } from "../utils/timelineAssetDrop";
19
19
  import { generateId } from "../utils/generateId";
20
20
  import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
21
- import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
21
+ import {
22
+ collectHtmlIds,
23
+ resolveDroppedAssetDuration,
24
+ resolveDroppedAssetHasAudio,
25
+ } from "../utils/studioHelpers";
22
26
  import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
23
27
  import { readFileContent } from "./timelineTimingSync";
24
28
  import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
@@ -113,6 +117,9 @@ export function useTimelineAssetDropOps({
113
117
  ? durationOverride
114
118
  : await resolveDroppedAssetDuration(pid, assetPath, kind);
115
119
  const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
120
+ // A video with an audio stream lands audible; the mixer only hears a
121
+ // <video> marked data-has-audio, and a muted drop was losing the sound.
122
+ const hasAudio = await resolveDroppedAssetHasAudio(pid, assetPath, kind);
116
123
  const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
117
124
  const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
118
125
 
@@ -146,6 +153,7 @@ export function useTimelineAssetDropOps({
146
153
  duration: normalizedDuration,
147
154
  track,
148
155
  zIndex: newElementZIndex,
156
+ hasAudio,
149
157
  geometry: fitTimelineAssetGeometry(
150
158
  null,
151
159
  resolveTimelineAssetCompositionSize(originalContent),
package/src/index.ts CHANGED
@@ -46,12 +46,36 @@ export {
46
46
  VideoThumbnail,
47
47
  CompositionThumbnail,
48
48
  useTimelinePlayer,
49
+ usePlayerHandle,
49
50
  resolveIframe,
50
51
  usePlayerStore,
51
52
  liveTime,
52
53
  formatTime,
53
54
  } from "./player";
54
- export type { TimelineElement, TimelineTimeRange } from "./player";
55
+ export type {
56
+ PlayerHandle,
57
+ PlayerHandleElement,
58
+ PlayerHandleListener,
59
+ PlayerHandleTimeListener,
60
+ TimelineElement,
61
+ TimelineTimeRange,
62
+ } from "./player";
63
+ export {
64
+ TimelineFrame,
65
+ TimelineLanes,
66
+ TimelineOverlays,
67
+ TimelinePlayhead,
68
+ TimelineRazorGuide,
69
+ TimelineRuler,
70
+ TimelineEmptyStatePart,
71
+ TimelineEditPopover,
72
+ TimelineClipMenu,
73
+ TimelineGapMenu,
74
+ TimelineKeyframeMenu,
75
+ TimelineShortcutHint,
76
+ } from "./player/components/TimelineParts";
77
+ export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
78
+ export type { TimelineTheme } from "./player/components/timelineTheme";
55
79
 
56
80
  // Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
57
81
  export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
@@ -95,9 +95,9 @@ export function LayerDisclosureRow({
95
95
  <span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
96
96
  ◇
97
97
  </span>
98
- {/* Wraps rather than truncating: a truncated name needs a hover to be
99
- read, which a scanned column cannot rely on. */}
100
- <span className="min-w-0 flex-1 wrap-break-word font-medium leading-tight">{name}</span>
98
+ <span title={name} className="min-w-0 flex-1 truncate font-medium leading-tight">
99
+ {name}
100
+ </span>
101
101
  <TrackClipCount clipCount={clipCount} />
102
102
  {children}
103
103
  {/* Anchored right, on every header that has one: the lane toggle is the
@@ -85,6 +85,8 @@ async function mountPlayer(props: Partial<PlayerProps> = {}) {
85
85
 
86
86
  const twoFrames = () => act(async () => void (await new Promise((r) => setTimeout(r, 80))));
87
87
 
88
+ const flushEffects = () => act(async () => await Promise.resolve());
89
+
88
90
  function createAudioIframe() {
89
91
  const iframe = document.createElement("iframe");
90
92
  document.body.appendChild(iframe);
@@ -216,6 +218,38 @@ describe("ready to show", () => {
216
218
  const painted = (player: TestHyperframesPlayer) =>
217
219
  act(() => void player.dispatchEvent(new Event("painted")));
218
220
 
221
+ it("waits two animation frames before notifying that the preview can show", async () => {
222
+ const callbacks: FrameRequestCallback[] = [];
223
+ const requestAnimationFrame = vi
224
+ .spyOn(window, "requestAnimationFrame")
225
+ .mockImplementation((callback) => {
226
+ callbacks.push(callback);
227
+ return callbacks.length;
228
+ });
229
+ try {
230
+ const onReadyToShowChange = vi.fn();
231
+ const { player } = await mountPlayer({ onReadyToShowChange });
232
+ const el = player as TestHyperframesPlayer;
233
+
234
+ loadAndReady(el);
235
+ painted(el);
236
+ await flushEffects();
237
+ expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
238
+ expect(callbacks).toHaveLength(1);
239
+
240
+ act(() => callbacks.shift()?.(0));
241
+ await flushEffects();
242
+ expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
243
+ expect(callbacks).toHaveLength(1);
244
+
245
+ act(() => callbacks.shift()?.(16));
246
+ await flushEffects();
247
+ expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
248
+ } finally {
249
+ requestAnimationFrame.mockRestore();
250
+ }
251
+ });
252
+
219
253
  it("promotes only once the player reports painted, not at ready or assetsready", async () => {
220
254
  const onReadyToShowChange = vi.fn();
221
255
  const { player } = await mountPlayer({ onReadyToShowChange });
@@ -135,6 +135,26 @@ function renderBasicTimeline() {
135
135
  return { host, root };
136
136
  }
137
137
 
138
+ function renderSharedAutomationTimeline(selectedElementId?: string) {
139
+ const host = createSizedTimelineHost(720);
140
+ const automation = JSON.stringify({
141
+ version: 1,
142
+ lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
143
+ });
144
+ usePlayerStore.setState({
145
+ duration: 8,
146
+ timelineReady: true,
147
+ ...(selectedElementId ? { selectedElementId } : {}),
148
+ elements: [
149
+ { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
150
+ { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
151
+ ],
152
+ });
153
+ const root = createRoot(host);
154
+ act(() => root.render(React.createElement(Timeline)));
155
+ return { host, root };
156
+ }
157
+
138
158
  describe("Timeline provider boundary", () => {
139
159
  it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
140
160
  usePlayerStore.setState({
@@ -621,21 +641,7 @@ describe("Timeline provider boundary", () => {
621
641
  // clip left the row's state depending on the selection, and a collapse that
622
642
  // only dropped the active clip left the row stuck open.
623
643
  it("expands and collapses every clip on a shared track together", () => {
624
- const host = createSizedTimelineHost(720);
625
- const automation = JSON.stringify({
626
- version: 1,
627
- lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
628
- });
629
- usePlayerStore.setState({
630
- duration: 8,
631
- timelineReady: true,
632
- elements: [
633
- { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
634
- { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
635
- ],
636
- });
637
- const root = createRoot(host);
638
- act(() => root.render(React.createElement(Timeline)));
644
+ const { host, root } = renderSharedAutomationTimeline();
639
645
 
640
646
  const row = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
641
647
  ?.parentElement;
@@ -668,22 +674,7 @@ describe("Timeline provider boundary", () => {
668
674
  // a lane to select its clip therefore made the handles vanish under the
669
675
  // pointer, which is the one gesture the read-only lane exists to support.
670
676
  it("keeps the automation lanes mounted when the selection moves along the row", () => {
671
- const host = createSizedTimelineHost(720);
672
- const automation = JSON.stringify({
673
- version: 1,
674
- lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
675
- });
676
- usePlayerStore.setState({
677
- duration: 8,
678
- timelineReady: true,
679
- selectedElementId: "narration-2",
680
- elements: [
681
- { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
682
- { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
683
- ],
684
- });
685
- const root = createRoot(host);
686
- act(() => root.render(React.createElement(Timeline)));
677
+ const { host, root } = renderSharedAutomationTimeline("narration-2");
687
678
  act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]')?.click());
688
679
 
689
680
  const before = [...host.querySelectorAll(".hf-automation-lane")];
@@ -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
+ });