@hyperframes/studio 0.8.58 → 0.8.60

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 (92) hide show
  1. package/dist/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-Di7GyCOP.js} +3 -3
  2. package/dist/assets/{index-DKJpHYYd.css → index-CBKb_0TI.css} +1 -1
  3. package/dist/assets/{index-d8ferjRc.js → index-Dpq2lkOm.js} +190 -190
  4. package/dist/assets/{index-ZJaRCDYV.js → index-Dx_pt9m4.js} +1 -1
  5. package/dist/assets/{index-BcE2OSVA.js → index-jAjhQTrB.js} +1 -1
  6. package/dist/index.d.ts +549 -13
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +23133 -21941
  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/StudioHeader.tsx +2 -13
  13. package/src/components/TimelineToolbar.test.tsx +42 -0
  14. package/src/components/TimelineToolbar.tsx +22 -8
  15. package/src/components/editor/audioInspectorInput.test.ts +55 -0
  16. package/src/components/editor/audioInspectorInput.ts +55 -0
  17. package/src/components/editor/propertyPanelFill.tsx +3 -2
  18. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
  19. package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
  20. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
  21. package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
  22. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
  23. package/src/components/editor/useColorGradingController.ts +2 -6
  24. package/src/components/icons/AudioMetersIcon.tsx +16 -0
  25. package/src/components/icons/InspectorIcon.tsx +18 -0
  26. package/src/components/icons/ReverseGradientIcon.tsx +23 -0
  27. package/src/components/icons/RippleEditIcon.tsx +19 -0
  28. package/src/components/nle/AudioMeterStrip.test.tsx +395 -0
  29. package/src/components/nle/AudioMeterStrip.tsx +430 -0
  30. package/src/components/nle/TimelinePane.tsx +29 -23
  31. package/src/components/ui/Menu.test.tsx +3 -4
  32. package/src/components/ui/Menu.tsx +18 -5
  33. package/src/components/ui/Select.tsx +7 -1
  34. package/src/components/ui/Tooltip.tsx +6 -1
  35. package/src/contexts/TimelineEditContext.tsx +4 -0
  36. package/src/hooks/useTimelineAssetDropOps.ts +9 -1
  37. package/src/index.ts +16 -0
  38. package/src/player/components/AudioWaveform.tsx +16 -7
  39. package/src/player/components/ImageThumbnail.tsx +3 -5
  40. package/src/player/components/LayerDisclosureRow.tsx +9 -4
  41. package/src/player/components/PlayheadIndicator.tsx +3 -3
  42. package/src/player/components/Timeline.test.ts +22 -31
  43. package/src/player/components/Timeline.tsx +34 -7
  44. package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
  45. package/src/player/components/TimelineAutomationLane.tsx +5 -5
  46. package/src/player/components/TimelineCanvas.tsx +28 -71
  47. package/src/player/components/TimelineClip.test.tsx +41 -4
  48. package/src/player/components/TimelineClip.tsx +25 -5
  49. package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
  50. package/src/player/components/TimelineClipDiamonds.tsx +2 -2
  51. package/src/player/components/TimelineClipFades.test.tsx +171 -0
  52. package/src/player/components/TimelineClipFades.tsx +302 -0
  53. package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
  54. package/src/player/components/TimelineEmptyState.tsx +1 -1
  55. package/src/player/components/TimelineFxButton.tsx +10 -8
  56. package/src/player/components/TimelineGroupHeader.tsx +18 -9
  57. package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
  58. package/src/player/components/TimelineGroupRow.tsx +1 -1
  59. package/src/player/components/TimelineLanes.test.tsx +14 -0
  60. package/src/player/components/TimelineOverlays.tsx +164 -165
  61. package/src/player/components/TimelineParts.test.tsx +101 -0
  62. package/src/player/components/TimelineParts.tsx +74 -0
  63. package/src/player/components/TimelinePartsParity.test.tsx +58 -0
  64. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  65. package/src/player/components/TimelineProvider.tsx +25 -0
  66. package/src/player/components/TimelineRazorInteraction.tsx +2 -2
  67. package/src/player/components/TimelineRuler.tsx +3 -2
  68. package/src/player/components/TimelineRulerPart.tsx +22 -0
  69. package/src/player/components/TimelineShortcutHint.tsx +2 -2
  70. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  71. package/src/player/components/TimelineTrackHeader.tsx +4 -6
  72. package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
  73. package/src/player/components/VideoThumbnail.tsx +3 -5
  74. package/src/player/components/timelineMotionStyles.test.ts +96 -30
  75. package/src/player/components/timelineProviderStateBuilders.ts +20 -0
  76. package/src/player/components/timelineTheme.test.ts +14 -0
  77. package/src/player/components/timelineTheme.ts +12 -11
  78. package/src/player/components/useTimelineProviderState.tsx +19 -12
  79. package/src/player/lib/timelineElementHelpers.ts +20 -0
  80. package/src/player/store/timelineElement.ts +5 -0
  81. package/src/styles/hex-baseline.json +4 -23
  82. package/src/styles/studio.css +43 -47
  83. package/src/styles/theme.css +109 -1
  84. package/src/styles/theme.test.ts +9 -0
  85. package/src/utils/audioMeterMath.test.ts +44 -0
  86. package/src/utils/audioMeterMath.ts +79 -0
  87. package/src/utils/audioMeterVisibility.ts +14 -0
  88. package/src/utils/studioHelpers.ts +21 -0
  89. package/src/utils/studioUiPreferences.test.ts +10 -0
  90. package/src/utils/studioUiPreferences.ts +5 -0
  91. package/src/utils/timelineAssetDrop.test.ts +32 -0
  92. package/src/utils/timelineAssetDrop.ts +6 -1
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useMemo, useRef } from "react";
1
+ import { memo, useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { useThumbnailLease } from "../../hooks/useThumbnailLease";
4
4
  import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
@@ -136,11 +136,12 @@ export const AudioWaveform = memo(function AudioWaveform({
136
136
  const end = Math.max(start + 1, Math.ceil(endFraction * peaks.length));
137
137
  const span = end - start;
138
138
  const barCount = Math.floor(width / BAR_STEP);
139
+ const waveformBarRgb = getComputedStyle(canvas).getPropertyValue("--timeline-waveform-bar-rgb");
139
140
  for (let index = 0; index < barCount; index++) {
140
141
  const peakIndex = start + Math.min(span - 1, Math.floor((index / barCount) * span));
141
142
  const amplitude = peaks[peakIndex] ?? 0;
142
143
  const barHeight = Math.max(2, amplitude * height);
143
- context.fillStyle = `rgba(75,163,210,${(0.45 + amplitude * 0.4).toFixed(2)})`;
144
+ context.fillStyle = `rgba(${waveformBarRgb},${(0.45 + amplitude * 0.4).toFixed(2)})`;
144
145
  context.fillRect(index * BAR_STEP, height - barHeight, BAR_WIDTH, barHeight);
145
146
  }
146
147
  }, [peaks, trimEndFraction, trimStartFraction]);
@@ -157,6 +158,16 @@ export const AudioWaveform = memo(function AudioWaveform({
157
158
  [draw],
158
159
  );
159
160
 
161
+ useEffect(() => {
162
+ const root = document.documentElement;
163
+ const observer = new MutationObserver(draw);
164
+ observer.observe(root, {
165
+ attributes: true,
166
+ attributeFilter: ["class", "data-chrome", "data-theme", "style"],
167
+ });
168
+ return () => observer.disconnect();
169
+ }, [draw]);
170
+
160
171
  useMountEffect(() => () => observerRef.current?.disconnect());
161
172
 
162
173
  return (
@@ -170,8 +181,7 @@ export const AudioWaveform = memo(function AudioWaveform({
170
181
  <div
171
182
  className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
172
183
  style={{
173
- background:
174
- "linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
184
+ background: "var(--timeline-thumbnail-shimmer)",
175
185
  }}
176
186
  />
177
187
  )}
@@ -187,8 +197,7 @@ export const AudioWaveform = memo(function AudioWaveform({
187
197
  style={{
188
198
  bottom: "20%",
189
199
  height: 2,
190
- background:
191
- "repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
200
+ background: "var(--timeline-waveform-error)",
192
201
  }}
193
202
  />
194
203
  <span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
@@ -200,7 +209,7 @@ export const AudioWaveform = memo(function AudioWaveform({
200
209
  <div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
201
210
  <span
202
211
  className="block truncate text-[9px] font-semibold leading-tight"
203
- style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
212
+ style={{ color: labelColor, textShadow: "var(--timeline-waveform-label-shadow)" }}
204
213
  >
205
214
  {label}
206
215
  </span>
@@ -91,8 +91,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
91
91
  <div
92
92
  className="absolute inset-0 animate-pulse"
93
93
  style={{
94
- background:
95
- "linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
94
+ background: "var(--timeline-thumbnail-shimmer)",
96
95
  }}
97
96
  />
98
97
  )}
@@ -100,13 +99,12 @@ export const ImageThumbnail = memo(function ImageThumbnail({
100
99
  <div
101
100
  className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
102
101
  style={{
103
- background:
104
- "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
102
+ background: "var(--timeline-thumbnail-label-gradient)",
105
103
  }}
106
104
  >
107
105
  <span
108
106
  className="block truncate text-[9px] font-semibold leading-tight"
109
- style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
107
+ style={{ color: labelColor, textShadow: "var(--timeline-thumbnail-label-shadow)" }}
110
108
  >
111
109
  {label}
112
110
  </span>
@@ -37,8 +37,10 @@ export function LaneToggleButton({
37
37
  title={`${isExpanded ? "Hide" : "Show"} lanes`}
38
38
  // h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
39
39
  // only the hit box grows.
40
- className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
41
- isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
40
+ className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)] ${
41
+ isExpanded
42
+ ? "text-[var(--timeline-accent)]"
43
+ : "text-[var(--timeline-handle)] hover:text-[var(--timeline-text-solid)]"
42
44
  }`}
43
45
  onPointerDown={(event) => event.stopPropagation()}
44
46
  onClick={(event) => {
@@ -86,13 +88,16 @@ export function LayerDisclosureRow({
86
88
  style={{
87
89
  width: columnWidth,
88
90
  height: TRACK_H,
89
- color: "#ffffff",
91
+ color: "var(--timeline-text-solid)",
90
92
  background: gutterBackground,
91
93
  }}
92
94
  >
93
95
  {/* Decorative: the disclosure button above already names the row's keyframe
94
96
  state, and aria-label on a plain span is not exposed reliably anyway. */}
95
- <span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
97
+ <span
98
+ aria-hidden="true"
99
+ className="shrink-0 text-[13px] leading-none text-[var(--timeline-text-dim)]"
100
+ >
96
101
  ◇
97
102
  </span>
98
103
  <span title={name} className="min-w-0 flex-1 truncate font-medium leading-tight">
@@ -26,8 +26,8 @@ interface PlayheadIndicatorProps {
26
26
  }
27
27
 
28
28
  export function PlayheadIndicator({
29
- color = "var(--hf-accent, #3CE6AC)",
30
- glowColor = "rgba(60,230,172,0.14)",
29
+ color = "var(--timeline-accent)",
30
+ glowColor = "var(--timeline-playhead-glow)",
31
31
  scrubbing = false,
32
32
  stickyHead = true,
33
33
  }: PlayheadIndicatorProps) {
@@ -93,7 +93,7 @@ export function PlayheadIndicator({
93
93
  background: scrubbing ? color : "transparent",
94
94
  border: `1.5px solid ${color}`,
95
95
  boxSizing: "border-box",
96
- boxShadow: `0 1px 3px rgba(0,0,0,0.55), 0 0 5px ${glowColor}`,
96
+ boxShadow: `0 1px 3px var(--timeline-playhead-shadow), 0 0 5px ${glowColor}`,
97
97
  transform: "rotate(45deg)",
98
98
  }}
99
99
  />
@@ -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
+ });
@@ -483,7 +483,7 @@ describe("TimelineAutomationLane", () => {
483
483
  // Bigger than the points around them, and ringed rather than recoloured — the
484
484
  // fill is the parameter's own colour and stays that way.
485
485
  expect(Math.min(...radii)).toBeGreaterThan(Math.max(...plain));
486
- expect(marked[0]?.getAttribute("stroke")).toBe("#fff");
486
+ expect(marked[0]?.getAttribute("stroke")).toBe("var(--timeline-text-solid)");
487
487
  });
488
488
 
489
489
  it("marks nothing when there is no range", () => {
@@ -75,7 +75,7 @@ function pointCircleStyle(
75
75
  radius: POINT_R * (dragging ? 1.3 : inRange ? 1.15 : 1),
76
76
  // A white ring rather than a different fill: the fill is the parameter's
77
77
  // own colour, and a lane with two envelopes on it is read by colour first.
78
- stroke: inRange ? "#fff" : "rgba(0,0,0,0.5)",
78
+ stroke: inRange ? "var(--timeline-text-solid)" : "var(--timeline-automation-point-muted)",
79
79
  strokeWidth: inRange ? 1.5 : 1,
80
80
  };
81
81
  }
@@ -132,7 +132,7 @@ function ReadOnlyNote({
132
132
  return (
133
133
  <div
134
134
  data-automation-readonly-note=""
135
- className="hf-automation-readonly-note pointer-events-none absolute rounded-[3px] bg-black/85 px-1.5 py-0.5 text-[9px] text-white/80"
135
+ className="hf-automation-readonly-note pointer-events-none absolute rounded-[3px] bg-[var(--timeline-overlay-bg-strong)] px-1.5 py-0.5 text-[9px] text-[var(--timeline-overlay-text)]"
136
136
  style={{ left: leftPx + 6, top: 2, zIndex: 3, maxWidth: Math.max(120, widthPx - 12) }}
137
137
  >
138
138
  {note}
@@ -470,7 +470,7 @@ export function TimelineAutomationLane({
470
470
  x2={PAD_X + widthPx}
471
471
  y1={pad + inner / 2}
472
472
  y2={pad + inner / 2}
473
- stroke="rgba(255,255,255,0.08)"
473
+ stroke="var(--timeline-automation-rail)"
474
474
  strokeDasharray="3 4"
475
475
  />
476
476
  {rangeSelection ? (
@@ -541,7 +541,7 @@ export function TimelineAutomationLane({
541
541
  cx={xOf(playheadSec)}
542
542
  cy={yOf(currentValue)}
543
543
  r={2.5}
544
- fill="#fff"
544
+ fill="var(--timeline-text-solid)"
545
545
  opacity={0.8}
546
546
  pointerEvents="none"
547
547
  />
@@ -561,7 +561,7 @@ export function TimelineAutomationLane({
561
561
 
562
562
  {hint ? (
563
563
  <div
564
- className="hf-automation-hint pointer-events-none absolute rounded-[3px] bg-black/80 px-1 py-0.5 font-mono text-[9px] text-white"
564
+ className="hf-automation-hint pointer-events-none absolute rounded-[3px] bg-[var(--timeline-overlay-bg)] px-1 py-0.5 font-mono text-[9px] text-[var(--timeline-text-solid)]"
565
565
  style={{ left: leftPx + 6, top: 2, zIndex: 3 }}
566
566
  >
567
567
  {hint}
@@ -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
@@ -140,7 +94,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
140
94
  left: props.contentOrigin + gap.start * props.pps,
141
95
  width: Math.max((gap.end - gap.start) * props.pps, 2),
142
96
  height: TRACK_H - CLIP_Y * 2,
143
- background: loud ? "rgba(60,230,172,0.18)" : "rgba(60,230,172,0.055)",
97
+ background: loud ? "var(--timeline-accent-soft)" : "var(--timeline-accent-faint)",
144
98
  borderRadius: 4,
145
99
  zIndex: 25,
146
100
  }}
@@ -158,8 +112,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
158
112
  left: props.contentOrigin + draggedClip.previewStart * props.pps,
159
113
  width: Math.max(draggedClip.element.duration * props.pps, 4),
160
114
  height: draggedClipHeight,
161
- border: "1px solid color-mix(in srgb, var(--color-accent) 55%, transparent)",
162
- background: "color-mix(in srgb, var(--color-accent) 12%, transparent)",
115
+ border: "1px solid color-mix(in srgb, var(--timeline-accent) 55%, transparent)",
116
+ background: "color-mix(in srgb, var(--timeline-accent) 12%, transparent)",
163
117
  borderRadius: 4,
164
118
  zIndex: 30,
165
119
  }}
@@ -178,8 +132,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
178
132
  left: props.contentOrigin + props.dropPreview.start * props.pps,
179
133
  width: DROP_PREVIEW_SECONDS * props.pps,
180
134
  height: TRACK_H - CLIP_Y * 2,
181
- border: "1px solid color-mix(in srgb, var(--color-accent) 55%, transparent)",
182
- background: "color-mix(in srgb, var(--color-accent) 12%, transparent)",
135
+ border: "1px solid color-mix(in srgb, var(--timeline-accent) 55%, transparent)",
136
+ background: "color-mix(in srgb, var(--timeline-accent) 12%, transparent)",
183
137
  borderRadius: 4,
184
138
  zIndex: 30,
185
139
  }}
@@ -197,8 +151,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
197
151
  left: props.contentOrigin,
198
152
  width: props.trackContentWidth,
199
153
  height: 1,
200
- background: "#3CE6AC",
201
- boxShadow: "0 0 3px rgba(60,230,172,0.5)",
154
+ background: "var(--timeline-accent)",
155
+ boxShadow: "0 0 3px var(--timeline-accent-glow)",
202
156
  zIndex: 55,
203
157
  }}
204
158
  />
@@ -213,11 +167,14 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
213
167
  top: RULER_H,
214
168
  bottom: 0,
215
169
  width: 1,
216
- background: snapGuide.type === "playhead" ? "#3CE6AC" : "rgba(255,255,255,0.6)",
170
+ background:
171
+ snapGuide.type === "playhead"
172
+ ? "var(--timeline-accent)"
173
+ : "var(--timeline-snap-guide)",
217
174
  boxShadow:
218
175
  snapGuide.type === "playhead"
219
- ? "0 0 6px rgba(60,230,172,0.5)"
220
- : "0 0 6px rgba(255,255,255,0.4)",
176
+ ? "0 0 6px var(--timeline-accent-glow)"
177
+ : "0 0 6px var(--timeline-text-dim)",
221
178
  zIndex: 60,
222
179
  }}
223
180
  />
@@ -247,8 +204,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
247
204
  top: props.marqueeRect.top,
248
205
  width: props.marqueeRect.width,
249
206
  height: props.marqueeRect.height,
250
- background: "rgba(60,230,172,0.10)",
251
- border: "1px dashed rgba(60,230,172,0.7)",
207
+ background: "var(--timeline-accent-fill)",
208
+ border: "1px dashed var(--timeline-accent-border)",
252
209
  borderRadius: 2,
253
210
  zIndex: 70,
254
211
  }}
@@ -266,9 +223,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
266
223
  width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps,
267
224
  top: RULER_H,
268
225
  bottom: 0,
269
- backgroundColor: "rgba(59, 130, 246, 0.12)",
270
- borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
271
- borderRight: "1px solid rgba(59, 130, 246, 0.4)",
226
+ backgroundColor: "var(--timeline-info-bg)",
227
+ borderLeft: "1px solid var(--timeline-info-border)",
228
+ borderRight: "1px solid var(--timeline-info-border)",
272
229
  zIndex: 50,
273
230
  }}
274
231
  />
@@ -69,7 +69,7 @@ function renderClip({
69
69
  describe("TimelineClip", () => {
70
70
  it("renders the clip label above custom content without showing default timecode", () => {
71
71
  const { host, root } = renderClip({
72
- element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 0.5, track: 0 },
72
+ element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 1, track: 0 },
73
73
  });
74
74
 
75
75
  expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero");
@@ -78,18 +78,55 @@ describe("TimelineClip", () => {
78
78
  act(() => root.unmount());
79
79
  });
80
80
 
81
- it("keeps selected narrow clips labeled even when they render custom content", () => {
81
+ it("drops the label chip under 60px even when the clip is selected", () => {
82
82
  const { host, root } = renderClip({
83
- element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 0.1, track: 0 },
83
+ element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 1, track: 0 },
84
+ pps: 59,
84
85
  isSelected: true,
85
86
  });
86
87
 
87
- expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("FX");
88
+ expect(host.querySelector(".timeline-clip__label")).toBeNull();
88
89
  expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
90
+ expect(host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe("picture");
89
91
 
90
92
  act(() => root.unmount());
91
93
  });
92
94
 
95
+ it("keeps the label at 60px and fills one frame under 24px", () => {
96
+ const labeled = renderClip({
97
+ element: { id: "wide", label: "City", tag: "video", start: 0, duration: 1, track: 0 },
98
+ pps: 200,
99
+ });
100
+ expect(labeled.host.querySelector(".timeline-clip__label")?.textContent).toBe("City");
101
+ expect(labeled.host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe(
102
+ "labeled",
103
+ );
104
+ expect(labeled.host.querySelector<HTMLElement>(".timeline-clip")?.style.borderRadius).toBe(
105
+ "var(--timeline-clip-radius)",
106
+ );
107
+ act(() => labeled.root.unmount());
108
+
109
+ const frame = renderClip({
110
+ element: { id: "sliver", label: "City", tag: "img", start: 0, duration: 1, track: 0 },
111
+ pps: 23,
112
+ isSelected: true,
113
+ });
114
+ expect(frame.host.querySelector(".timeline-clip__label")).toBeNull();
115
+ expect(frame.host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe("frame");
116
+ act(() => frame.root.unmount());
117
+ });
118
+
119
+ it("gives audio clips the pill radius", () => {
120
+ const { host, root } = renderClip({
121
+ element: { id: "vo", label: "Voice", tag: "audio", start: 0, duration: 2, track: 1 },
122
+ pps: 100,
123
+ });
124
+ expect(host.querySelector<HTMLElement>(".timeline-clip")?.style.borderRadius).toBe(
125
+ "var(--timeline-clip-audio-radius)",
126
+ );
127
+ act(() => root.unmount());
128
+ });
129
+
93
130
  it("marks hidden clips for active-state suppression", () => {
94
131
  const { host, root } = renderClip({
95
132
  element: {