@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
@@ -10,6 +10,7 @@ import { useTimelineClipDrag } from "./useTimelineClipDrag";
10
10
  import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvider";
11
11
  import {
12
12
  buildTimelineMeta,
13
+ resolveMultiDragPreview,
13
14
  resolveRenderClipContent,
14
15
  resolveResizingElementIds,
15
16
  shouldIgnoreTimelinePointerDown,
@@ -30,7 +31,7 @@ import {
30
31
  } from "./useTimelineTrackLayout";
31
32
  import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
32
33
  import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
33
- import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
34
+ import { TimelineRazorGuideOverlay, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
34
35
  import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
35
36
  import {
36
37
  getEffectiveTimelineDuration,
@@ -44,6 +45,8 @@ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
44
45
  import { useTimelineActiveClips } from "./useTimelineActiveClips";
45
46
  import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
46
47
  import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
48
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
49
+ import { resolveSnapGuide } from "./timelineSnapping";
47
50
  export function useTimelineProviderState({
48
51
  onSeek,
49
52
  onDrillDown,
@@ -90,10 +93,10 @@ export function useTimelineProviderState({
90
93
  onSplitElement: onSplitElementOverride,
91
94
  });
92
95
  const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
96
+ const editContext = useTimelineEditContextOptional();
93
97
  const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
94
98
  useMusicBeatAnalysis();
95
- const rawElements = usePlayerStore((s) => s.elements);
96
- const timelineElements = rawElements;
99
+ const timelineElements = usePlayerStore((s) => s.elements);
97
100
  const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
98
101
  const duration = usePlayerStore((s) => s.duration);
99
102
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
@@ -108,7 +111,6 @@ export function useTimelineProviderState({
108
111
  );
109
112
  // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
110
113
  const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
111
- const contentGutter = labelMode ? GUTTER : 0;
112
114
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
113
115
  const currentTime = usePlayerStore((s) => s.currentTime);
114
116
  const beatDragging = usePlayerStore((s) => s.beatDragging);
@@ -130,8 +132,8 @@ export function useTimelineProviderState({
130
132
  }, []);
131
133
  const lastScrollLeftRef = useRef(0);
132
134
  const effectiveDuration = useMemo(
133
- () => getEffectiveTimelineDuration(duration, rawElements),
134
- [duration, rawElements],
135
+ () => getEffectiveTimelineDuration(duration, timelineElements),
136
+ [duration, timelineElements],
135
137
  );
136
138
  const keyframeCache = usePlayerStore((s) => s.keyframeCache);
137
139
  useAutoExpandKeyframedClips(gsapAnimations);
@@ -406,7 +408,6 @@ export function useTimelineProviderState({
406
408
  handlePointerCancel,
407
409
  } = overlaysProps;
408
410
  const { rangeSelection, setRangeSelection } = overlays;
409
-
410
411
  const laneGapStrips = useTimelineGapHighlights({
411
412
  gapHighlight,
412
413
  tracks,
@@ -416,25 +417,24 @@ export function useTimelineProviderState({
416
417
  dragActive: draggedClip?.started === true || resizingClip != null,
417
418
  displayDuration,
418
419
  });
419
-
420
420
  const { major, minor, majorTickInterval } = useTimelineTicks(
421
421
  displayDuration,
422
422
  pps,
423
423
  timeDisplayMode,
424
424
  timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
425
425
  );
426
-
427
426
  const getPreviewElement = useCallback(
428
427
  (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
429
428
  [resizingClip],
430
429
  );
431
-
430
+ const draggedElement = draggedClip?.element ?? null;
431
+ const multiDragPreview = resolveMultiDragPreview(draggedClip, selectedElementIds);
432
432
  const canvasProps = {
433
433
  major,
434
434
  minor,
435
435
  pps,
436
436
  contentOrigin,
437
- contentGutter,
437
+ contentGutter: labelMode ? GUTTER : 0,
438
438
  trackContentWidth: displayContentWidth,
439
439
  totalH: displayLayout.totalH,
440
440
  effectiveDuration,
@@ -501,6 +501,13 @@ export function useTimelineProviderState({
501
501
  onResizeElement,
502
502
  onMoveElement,
503
503
  beatDragging,
504
+ draggedElement,
505
+ snapGuide: resolveSnapGuide(draggedClip, resizingClip),
506
+ multiDragPreview,
507
+ onToggleTrackHidden: editContext.onToggleTrackHidden,
508
+ onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
509
+ onRazorSplit: editContext.onRazorSplit,
510
+ onRazorSplitAll: editContext.onRazorSplitAll,
504
511
  };
505
512
  const timelineRenderClipContent = resolveRenderClipContent(
506
513
  timelineFocus.rowVirtualizationActive,
@@ -559,7 +566,7 @@ export function useTimelineProviderState({
559
566
  labelColumnWidth: LABEL_COL_W,
560
567
  razorGuide:
561
568
  activeTool === "razor" && razorGuideX !== null ? (
562
- <TimelineRazorGuide x={razorGuideX} />
569
+ <TimelineRazorGuideOverlay x={razorGuideX} />
563
570
  ) : null,
564
571
  });
565
572
  const contextValue: TimelineContextValue = {
@@ -12,6 +12,7 @@ import type { ClipManifestClip } from "./playbackTypes";
12
12
  import { isFinitePositive } from "./playbackAdapter";
13
13
  import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
14
14
  import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
15
+ import { readElementFades } from "@hyperframes/core/audio-fade";
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Layer-reveal lift transparency
@@ -173,8 +174,27 @@ function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element):
173
174
  }
174
175
  }
175
176
 
177
+ /** Sets or clears an optional field, so a re-parse after the attribute is removed drops it. */
178
+ function setOptional<K extends keyof TimelineElement>(
179
+ entry: TimelineElement,
180
+ key: K,
181
+ value: TimelineElement[K] | undefined,
182
+ ): void {
183
+ if (value === undefined) delete entry[key];
184
+ else entry[key] = value;
185
+ }
186
+
187
+ /** `data-has-audio` and the clip-edge fades: what the mixer hears and how it enters and leaves. */
188
+ function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
189
+ setOptional(entry, "hasAudio", el.getAttribute("data-has-audio") === "true" ? true : undefined);
190
+ const fades = readElementFades(el);
191
+ setOptional(entry, "fadeIn", fades.fadeIn > 0 ? fades.fadeIn : undefined);
192
+ setOptional(entry, "fadeOut", fades.fadeOut > 0 ? fades.fadeOut : undefined);
193
+ }
194
+
176
195
  export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
177
196
  applyPlaybackMetadataFromElement(entry, el);
197
+ applyAudioMetadataFromElement(entry, el);
178
198
 
179
199
  const mediaEl = resolveMediaElement(el);
180
200
  if (!mediaEl) return;
@@ -54,6 +54,11 @@ export interface TimelineElement {
54
54
  playbackRate?: number;
55
55
  sourceDuration?: number;
56
56
  volume?: number;
57
+ /** `data-has-audio="true"` on a video: its sound is mixed, so it takes audio affordances (fades). */
58
+ hasAudio?: boolean;
59
+ /** Clip-edge fades from `data-fade-in` / `data-fade-out`, seconds; absent means none. */
60
+ fadeIn?: number;
61
+ fadeOut?: number;
57
62
  /** Verbatim `data-fx-chain` / `data-automation`; see automationLaneData. */
58
63
  fxChain?: string;
59
64
  automation?: string;
@@ -1,5 +1,5 @@
1
1
  {
2
- "total": 385,
2
+ "total": 302,
3
3
  "files": {
4
4
  "packages/studio/src/captions/components/CaptionOverlay.tsx": 5,
5
5
  "packages/studio/src/captions/components/CaptionTimeline.tsx": 11,
@@ -49,35 +49,16 @@
49
49
  "packages/studio/src/components/sidebar/AudioRow.tsx": 2,
50
50
  "packages/studio/src/components/ui/HyperframesMark.tsx": 4,
51
51
  "packages/studio/src/hooks/useInlineTextEdit.ts": 1,
52
- "packages/studio/src/player/components/AudioWaveform.tsx": 6,
52
+ "packages/studio/src/player/components/AudioWaveform.tsx": 1,
53
53
  "packages/studio/src/player/components/BeatStrip.tsx": 6,
54
54
  "packages/studio/src/player/components/CompositionThumbnail.tsx": 2,
55
- "packages/studio/src/player/components/ImageThumbnail.tsx": 6,
56
- "packages/studio/src/player/components/LayerDisclosureRow.tsx": 3,
57
55
  "packages/studio/src/player/components/Player.tsx": 2,
58
56
  "packages/studio/src/player/components/PlayerControls.tsx": 1,
59
- "packages/studio/src/player/components/PlayheadIndicator.tsx": 3,
60
57
  "packages/studio/src/player/components/ShortcutsPanel.tsx": 7,
61
58
  "packages/studio/src/player/components/SpeedMenu.tsx": 2,
62
- "packages/studio/src/player/components/TimelineAutomationLane.tsx": 4,
63
- "packages/studio/src/player/components/TimelineCanvas.tsx": 13,
64
- "packages/studio/src/player/components/TimelineClipDiamonds.tsx": 2,
65
- "packages/studio/src/player/components/TimelineDiamondConnectors.tsx": 2,
66
- "packages/studio/src/player/components/TimelineEmptyState.tsx": 1,
67
- "packages/studio/src/player/components/TimelineFxButton.tsx": 8,
68
- "packages/studio/src/player/components/TimelineGroupHeader.tsx": 6,
69
- "packages/studio/src/player/components/TimelineGroupLaneLabels.tsx": 1,
70
- "packages/studio/src/player/components/TimelineGroupRow.tsx": 1,
71
- "packages/studio/src/player/components/TimelineRazorInteraction.tsx": 1,
72
- "packages/studio/src/player/components/TimelineRuler.tsx": 1,
73
- "packages/studio/src/player/components/TimelineShortcutHint.tsx": 2,
74
- "packages/studio/src/player/components/TimelineTrackHeader.tsx": 2,
75
- "packages/studio/src/player/components/TimelineTrackPlainHeader.tsx": 2,
76
- "packages/studio/src/player/components/VideoThumbnail.tsx": 6,
77
- "packages/studio/src/player/components/timelineTheme.ts": 5,
78
59
  "packages/studio/src/player/components/trackHeaderLabelRows.tsx": 6,
79
- "packages/studio/src/styles/studio.css": 63,
80
60
  "packages/studio/src/telemetry/client.ts": 1,
81
- "packages/studio/src/webmcp/webmcpTestUtils.ts": 1
61
+ "packages/studio/src/webmcp/webmcpTestUtils.ts": 1,
62
+ "packages/studio/src/styles/studio.css": 48
82
63
  }
83
64
  }
@@ -202,7 +202,10 @@ body {
202
202
  .timeline-clip {
203
203
  background-color: var(--clip-bg);
204
204
  border: 1px solid var(--clip-border);
205
- border-radius: 8px;
205
+ border-radius: var(--timeline-clip-radius);
206
+ box-shadow:
207
+ inset 0 1px 0 var(--timeline-clip-highlight),
208
+ var(--timeline-clip-shadow);
206
209
  }
207
210
 
208
211
  .timeline-clip::before {
@@ -227,16 +230,19 @@ body {
227
230
  accented visual clips — so the audio zone at the bottom is visually separate
228
231
  at a glance (the timeline's kinds: visual on top, audio below). */
229
232
  .timeline-clip.is-audio {
230
- background-color: rgba(167, 139, 250, 0.16);
231
- border-color: rgba(167, 139, 250, 0.4);
233
+ background-color: var(--timeline-clip-audio-bg);
234
+ border-color: var(--timeline-clip-audio-border);
235
+ border-radius: var(--timeline-clip-audio-radius);
232
236
  }
233
237
 
234
238
  .timeline-clip.is-audio.is-hovered {
235
- background-color: rgba(167, 139, 250, 0.24);
239
+ background-color: var(--timeline-clip-audio-bg-hover);
236
240
  }
237
241
 
238
242
  .timeline-clip.is-audio.is-dragging {
239
- background-color: rgba(60, 52, 84, 0.96);
243
+ box-shadow:
244
+ inset 0 0 0 999px var(--timeline-clip-audio-bg-dragging),
245
+ 0 8px 24px var(--timeline-clip-shadow-dragging);
240
246
  }
241
247
 
242
248
  .timeline-clip.is-hovered {
@@ -248,53 +254,56 @@ body {
248
254
  }
249
255
 
250
256
  .timeline-clip.is-selected {
251
- box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.85);
252
- }
253
-
254
- .timeline-clip[data-active].is-selected {
255
- box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.85);
257
+ outline: 1.5px solid var(--timeline-clip-selection);
258
+ outline-offset: -1.5px;
256
259
  }
257
260
 
258
- .timeline-clip.is-dragging {
259
- /* Solid background so the picked-up clip reads clearly while dragging — audio
260
- clips (waveform drawn on a separate layer) would otherwise look transparent. */
261
- background-color: var(--clip-bg-dragging);
262
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
261
+ .timeline-clip.is-selected::after {
262
+ content: "";
263
+ position: absolute;
264
+ inset: 0;
265
+ z-index: 4;
266
+ border-radius: inherit;
267
+ box-shadow: inset 0 0 0 1px var(--timeline-clip-selection-highlight);
268
+ pointer-events: none;
263
269
  }
264
270
 
265
- /* Keep the white selection outline while dragging (a plain box-shadow alone gets
266
- overridden by .is-dragging's drop shadow, so re-state both together). */
267
- .timeline-clip.is-selected.is-dragging {
268
- box-shadow:
269
- 0 0 0 1.5px rgba(255, 255, 255, 0.85),
270
- 0 8px 24px rgba(0, 0, 0, 0.4);
271
+ .timeline-clip[data-active].is-selected {
272
+ outline: 1.5px solid var(--timeline-clip-selection);
273
+ outline-offset: -1.5px;
271
274
  }
272
275
 
273
- .timeline-clip[data-active].is-selected.is-dragging {
276
+ .timeline-clip.is-dragging {
277
+ /* Veil is a shadow, not background-color, so drop does not fade the hover fill. */
274
278
  box-shadow:
275
- 0 0 0 1.5px rgba(255, 255, 255, 0.85),
276
- 0 8px 24px rgba(0, 0, 0, 0.4);
279
+ inset 0 0 0 999px var(--clip-bg-dragging),
280
+ 0 8px 24px var(--timeline-clip-shadow-dragging);
277
281
  }
278
282
 
279
283
  .timeline-clip__label {
280
284
  position: absolute;
281
- top: 6px;
282
- left: 12px;
283
- right: 10px;
285
+ top: 5px;
286
+ left: 6px;
284
287
  z-index: 6;
288
+ width: fit-content;
289
+ max-width: calc(100% - 22px);
285
290
  overflow: hidden;
286
- color: rgba(255, 255, 255, 0.5);
291
+ padding: 1px 5px;
292
+ border-radius: 4px;
293
+ background: var(--timeline-clip-chip-bg);
294
+ color: var(--timeline-clip-chip-text);
287
295
  font-size: 10px;
288
296
  font-weight: 500;
289
- line-height: 1;
297
+ line-height: 16px;
290
298
  pointer-events: none;
291
- text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
292
299
  text-overflow: ellipsis;
293
300
  white-space: nowrap;
294
301
  }
295
302
 
296
- .timeline-clip[data-active] .timeline-clip__label {
297
- color: rgba(232, 255, 247, 0.95);
303
+ .timeline-clip.is-audio .timeline-clip__label {
304
+ top: 11px;
305
+ left: 9px;
306
+ background: var(--timeline-clip-chip-audio-bg);
298
307
  }
299
308
 
300
309
  .timeline-clip__timecode {
@@ -302,7 +311,7 @@ body {
302
311
  bottom: 6px;
303
312
  left: 12px;
304
313
  overflow: hidden;
305
- color: rgba(255, 255, 255, 0.34);
314
+ color: var(--timeline-tick-text);
306
315
  font-family: var(--font-mono);
307
316
  font-size: 9px;
308
317
  font-variant-numeric: tabular-nums;
@@ -313,20 +322,7 @@ body {
313
322
 
314
323
  @media (prefers-reduced-motion: no-preference) {
315
324
  .timeline-clip {
316
- transition:
317
- background-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
318
- border-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
319
- box-shadow 200ms cubic-bezier(0.22, 1, 0.36, 1),
320
- color 200ms cubic-bezier(0.22, 1, 0.36, 1),
321
- opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
322
- }
323
-
324
- .timeline-clip::before {
325
- transition: opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
326
- }
327
-
328
- .timeline-clip__label {
329
- transition: color 200ms cubic-bezier(0.22, 1, 0.36, 1);
325
+ transition: background-color 120ms ease-out;
330
326
  }
331
327
 
332
328
  .timeline-clip__handle-bar {
@@ -62,6 +62,42 @@
62
62
  --timeline-tick-major: rgba(255, 255, 255, 0.1);
63
63
  --timeline-tick-minor: rgba(255, 255, 255, 0.06);
64
64
  --timeline-clip-bg: rgba(255, 255, 255, 0.12);
65
+ --timeline-clip-label-active: rgba(232, 255, 247, 0.95);
66
+ --timeline-clip-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
67
+ --timeline-clip-audio-bg: rgba(167, 139, 250, 0.16);
68
+ --timeline-clip-audio-border: rgba(167, 139, 250, 0.4);
69
+ --timeline-clip-audio-bg-hover: rgba(167, 139, 250, 0.24);
70
+ --timeline-clip-audio-bg-dragging: rgba(60, 52, 84, 0.96);
71
+ --timeline-clip-selection: rgba(255, 255, 255, 0.85);
72
+ --timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
73
+ --timeline-clip-radius: 8px;
74
+ --timeline-clip-audio-radius: 21px;
75
+ --timeline-clip-highlight: rgb(255 255 255 / 0.08);
76
+ --timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
77
+ --timeline-clip-chip-bg: rgba(15, 22, 18, 0.73);
78
+ --timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7);
79
+ --timeline-clip-chip-text: #ffffff;
80
+ --timeline-shortcut-bg: rgba(17, 23, 35, 0.84);
81
+ --timeline-thumbnail-shimmer: linear-gradient(
82
+ 90deg,
83
+ rgba(255, 255, 255, 0.02) 0%,
84
+ rgba(255, 255, 255, 0.05) 50%,
85
+ rgba(255, 255, 255, 0.02) 100%
86
+ );
87
+ --timeline-thumbnail-label-gradient: linear-gradient(
88
+ to top,
89
+ rgba(0, 0, 0, 0.85) 0%,
90
+ rgba(0, 0, 0, 0.4) 60%,
91
+ transparent 100%
92
+ );
93
+ --timeline-thumbnail-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
94
+ --timeline-waveform-bar-rgb: 75, 163, 210;
95
+ --timeline-waveform-error: repeating-linear-gradient(
96
+ 90deg,
97
+ rgba(75, 163, 210, 0.35) 0 2px,
98
+ transparent 2px 5px
99
+ );
100
+ --timeline-waveform-label-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
65
101
  --timeline-clip-bg-active: rgba(60, 230, 172, 0.2);
66
102
  --timeline-clip-bg-hover: rgba(255, 255, 255, 0.16);
67
103
  --timeline-clip-bg-dragging: rgba(38, 42, 52, 0.96);
@@ -69,11 +105,49 @@
69
105
  --timeline-clip-border-hover: rgba(60, 230, 172, 0.75);
70
106
  --timeline-clip-border-active: rgba(60, 230, 172, 0.55);
71
107
  --timeline-handle: rgba(255, 255, 255, 0.55);
108
+ /* Audio clip fades: the shaded wedge over the faded gain, and the corner dots. */
109
+ --timeline-fade-shade: rgba(0, 0, 0, 0.38);
110
+ --timeline-fade-dot-active: rgba(255, 255, 255, 1);
111
+ --timeline-fade-dot-ring: rgba(0, 0, 0, 0.55);
72
112
  --timeline-resize-seam: rgba(255, 255, 255, 0.12);
73
113
  --timeline-resize-active: rgba(255, 255, 255, 0.24);
74
114
  --timeline-track-clip-fill: rgba(255, 255, 255, 0.055);
75
115
  --timeline-track-clip-active: rgba(60, 230, 172, 0.16);
76
116
  --timeline-track-label: rgba(255, 255, 255, 0.5);
117
+ --timeline-border-strong: rgba(255, 255, 255, 0.2);
118
+ --timeline-group-member-tint: rgba(255, 255, 255, 0.035);
119
+ --timeline-overlay-text: rgba(255, 255, 255, 0.8);
120
+ --timeline-accent: #3ce6ac;
121
+ --timeline-accent-soft: rgba(60, 230, 172, 0.18);
122
+ --timeline-accent-faint: rgba(60, 230, 172, 0.055);
123
+ --timeline-accent-fill: rgba(60, 230, 172, 0.1);
124
+ --timeline-accent-glow: rgba(60, 230, 172, 0.5);
125
+ --timeline-accent-border: rgba(60, 230, 172, 0.7);
126
+ --timeline-accent-rail: rgba(60, 230, 172, 0.35);
127
+ --timeline-accent-hover: rgba(60, 230, 172, 0.25);
128
+ --timeline-accent-button-bg: rgba(60, 230, 172, 0.15);
129
+ --timeline-danger: rgba(239, 68, 68, 0.7);
130
+ --timeline-info-bg: rgba(59, 130, 246, 0.12);
131
+ --timeline-info-border: rgba(59, 130, 246, 0.4);
132
+ --timeline-dialog-bg: #1b1b1f;
133
+ --timeline-diamond-bg: #171717;
134
+ --timeline-diamond-border: rgba(255, 255, 255, 0.14);
135
+ --timeline-text-solid: #ffffff;
136
+ --timeline-text-faint: rgba(255, 255, 255, 0.35);
137
+ --timeline-text-dim: rgba(255, 255, 255, 0.4);
138
+ --timeline-text-soft: rgba(255, 255, 255, 0.75);
139
+ --timeline-text-bright: rgba(255, 255, 255, 0.7);
140
+ --timeline-snap-guide: rgba(255, 255, 255, 0.6);
141
+ --timeline-overlay-bg: rgba(0, 0, 0, 0.8);
142
+ --timeline-overlay-bg-strong: rgba(0, 0, 0, 0.85);
143
+ --timeline-input-bg: rgba(0, 0, 0, 0.3);
144
+ --timeline-automation-rail: rgba(255, 255, 255, 0.08);
145
+ --timeline-automation-point-muted: rgba(0, 0, 0, 0.5);
146
+ --timeline-diamond-muted: #a3a3a3;
147
+ --timeline-beat: rgb(34, 197, 94);
148
+ --timeline-clip-shadow-dragging: rgba(0, 0, 0, 0.4);
149
+ --timeline-playhead-glow: rgba(60, 230, 172, 0.14);
150
+ --timeline-playhead-shadow: rgba(0, 0, 0, 0.55);
77
151
 
78
152
  /* -------------------------------------------------------------- geometry */
79
153
 
@@ -142,7 +216,14 @@
142
216
  --duration-press: 120ms;
143
217
  --duration-hover: 150ms;
144
218
  --duration-expand: 160ms;
145
- --duration-open: 180ms;
219
+ /* Menus, popovers, and dropdowns. Open is ease-out; close is shorter and ease-in. */
220
+ --duration-open: 120ms;
221
+ --duration-close: 90ms;
222
+ /* Tooltips use the same enter shape on a shorter open. */
223
+ --duration-tooltip: 100ms;
224
+ /* Visible on the first frame. Never enter from scale 0 or opacity 0. */
225
+ --popup-enter-opacity: 0.8;
226
+ --popup-enter-scale: 0.97;
146
227
 
147
228
  /* ------------------------------------------------------------------ icon */
148
229
 
@@ -260,6 +341,33 @@
260
341
  --color-white: #fff;
261
342
  }
262
343
 
344
+ /* One paper block. A host sets data-theme="paper" on the document element. */
345
+ :root[data-theme="paper"] {
346
+ --timeline-shell-bg: #f4f6f3;
347
+ --timeline-row-bg: #edf0ed;
348
+ --timeline-clip-bg: #c9d1cb;
349
+ --timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.12);
350
+ --timeline-clip-highlight: rgb(255 255 255 / 0.08);
351
+ --timeline-clip-selection: #0f766e;
352
+ --timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
353
+ --timeline-clip-chip-bg: rgba(15, 22, 18, 0.73);
354
+ --timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7);
355
+ --timeline-clip-chip-text: #ffffff;
356
+ --timeline-clip-audio-bg: rgb(150, 128, 180);
357
+ --timeline-clip-audio-bg-hover: rgb(135, 109, 170);
358
+ --timeline-clip-audio-bg-dragging: rgb(232, 224, 242);
359
+ --timeline-clip-audio-border: rgba(113, 80, 155, 0.45);
360
+ --timeline-clip-audio-muted-bg: rgb(156, 161, 157);
361
+ --timeline-audio-link: #626d66;
362
+ --timeline-waveform-bar-rgb: 20, 12, 32;
363
+ --timeline-waveform-muted-rgb: 24, 28, 26;
364
+ --timeline-waveform-error: repeating-linear-gradient(
365
+ 90deg,
366
+ rgba(20, 12, 32, 0.35) 0 2px,
367
+ transparent 2px 5px
368
+ );
369
+ }
370
+
263
371
  /* Motion durations as utilities, since Tailwind has no `--duration-*`
264
372
  * namespace. Each one zeroes itself under `prefers-reduced-motion`, so a
265
373
  * caller cannot forget the reduced-motion case. */
@@ -109,6 +109,15 @@ describe("studio theme", () => {
109
109
  expect(vars.get("--text-lg")).toBe("1.125rem");
110
110
  });
111
111
 
112
+ it("opens menus from a visible shape and closes them faster", () => {
113
+ expect(declaredValue("--duration-open")).toBe("120ms");
114
+ expect(declaredValue("--duration-close")).toBe("90ms");
115
+ expect(declaredValue("--duration-tooltip")).toBe("100ms");
116
+ expect(declaredValue("--popup-enter-opacity")).toBe("0.8");
117
+ expect(declaredValue("--popup-enter-scale")).toBe("0.97");
118
+ expect(declaredValue("--duration-hover")).toBe("150ms");
119
+ });
120
+
112
121
  it("compiles a motion-duration utility with a reduced-motion variant", async () => {
113
122
  const css = await build("studio.css", ["duration-press", "duration-open"]);
114
123
 
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ levelToFraction,
4
+ markFraction,
5
+ SILENT_CHANNEL,
6
+ stepChannel,
7
+ stepPair,
8
+ } from "./audioMeterMath";
9
+
10
+ describe("levelToFraction", () => {
11
+ it("puts each dB mark on its equally spaced stop", () => {
12
+ [1, 0.8, 0.6, 0.4, 0.2].forEach((f, i) =>
13
+ expect(markFraction([0, -3, -6, -12, -24][i]!)).toBeCloseTo(f, 6),
14
+ );
15
+ });
16
+ it("clamps silence and overs", () => {
17
+ expect(levelToFraction(0)).toBe(0);
18
+ expect(levelToFraction(4)).toBe(1);
19
+ });
20
+ it("is monotonic between marks", () => {
21
+ expect(levelToFraction(0.5)).toBeGreaterThan(markFraction(-12));
22
+ expect(levelToFraction(0.5)).toBeLessThan(markFraction(-3));
23
+ });
24
+ });
25
+
26
+ describe("stepChannel", () => {
27
+ it("attacks instantly, holds the peak, then falls", () => {
28
+ let ch = stepChannel(SILENT_CHANNEL, 1, 0, 16);
29
+ expect(ch.level).toBe(1);
30
+ ch = stepChannel(ch, 0, 500, 16);
31
+ expect(ch.peak).toBe(1);
32
+ expect(ch.level).toBeLessThan(1);
33
+ ch = stepChannel(ch, 0, 2000, 500);
34
+ expect(ch.peak).toBeLessThan(1);
35
+ });
36
+ });
37
+
38
+ describe("stepPair", () => {
39
+ it("returns the same pair while silent at rest, and moves on sound", () => {
40
+ const rest = stepPair(undefined, undefined, 0, 16);
41
+ expect(stepPair(rest, { l: 0, r: 0 }, 16, 16)).toBe(rest);
42
+ expect(stepPair(rest, { l: 1, r: 0 }, 32, 16)).not.toBe(rest);
43
+ });
44
+ });
@@ -0,0 +1,79 @@
1
+ import { useMemo } from "react";
2
+ import { audioDbToGain, audioGainToDb } from "@hyperframes/core/audio-gain";
3
+ import { usePlayerStore, type TimelineElement } from "../player";
4
+ import { isAudioTimelineElement } from "./timelineInspector";
5
+
6
+ /** dB stops of the meter scale, top to bottom; equal spacing between stops. */
7
+ export const METER_DB_MARKS = [0, -3, -6, -12, -24] as const;
8
+ const FLOOR_DB = -60;
9
+ const STOPS = [...METER_DB_MARKS, FLOOR_DB];
10
+
11
+ const FALL_PER_SECOND = 0.9;
12
+ const HOLD_MS = 1200;
13
+
14
+ /** Linear peak (0..1) to a 0..1 bar fraction along the piecewise-linear dB scale. */
15
+ export function levelToFraction(linear: number): number {
16
+ const db = linear > 0 ? audioGainToDb(linear) : FLOOR_DB;
17
+ if (db >= 0) return 1;
18
+ if (db <= FLOOR_DB) return 0;
19
+ const j = STOPS.findIndex((stop) => db >= stop);
20
+ const hi = STOPS[j - 1] as number;
21
+ const lo = STOPS[j] as number;
22
+ return (STOPS.length - 1 - j + (db - lo) / (hi - lo)) / (STOPS.length - 1);
23
+ }
24
+
25
+ /** Fraction of the bar height at which a dB mark sits, 0 at the bottom. */
26
+ export function markFraction(db: number): number {
27
+ return levelToFraction(audioDbToGain(db));
28
+ }
29
+
30
+ export interface MeterChannel {
31
+ level: number;
32
+ peak: number;
33
+ peakAt: number;
34
+ }
35
+
36
+ export const SILENT_CHANNEL: MeterChannel = { level: 0, peak: 0, peakAt: 0 };
37
+
38
+ /** Instant attack, steady fall; the peak holds for HOLD_MS, then falls with the bar. */
39
+ export function stepChannel(
40
+ prev: MeterChannel,
41
+ linear: number,
42
+ now: number,
43
+ dtMs: number,
44
+ ): MeterChannel {
45
+ const fall = (dtMs / 1000) * FALL_PER_SECOND;
46
+ const target = levelToFraction(linear);
47
+ const level = Math.max(target, prev.level - fall);
48
+ if (target >= prev.peak) return { level, peak: target, peakAt: now };
49
+ if (now - prev.peakAt < HOLD_MS) return { level, peak: prev.peak, peakAt: prev.peakAt };
50
+ return { level, peak: Math.max(target, prev.peak - fall), peakAt: prev.peakAt };
51
+ }
52
+
53
+ export type MeterPair = [MeterChannel, MeterChannel];
54
+
55
+ const atRest = (ch: MeterChannel): boolean => ch.level === 0 && ch.peak === 0;
56
+
57
+ /** Steps both channels; returns `prev` itself while silent and at rest, so callers can skip the paint. */
58
+ export function stepPair(
59
+ prev: MeterPair | undefined,
60
+ raw: { l: number; r: number } | undefined,
61
+ now: number,
62
+ dtMs: number,
63
+ ): MeterPair {
64
+ const [l, r] = prev ?? [SILENT_CHANNEL, SILENT_CHANNEL];
65
+ const silent = !raw?.l && !raw?.r;
66
+ if (prev && silent && atRest(l) && atRest(r)) return prev;
67
+ return [stepChannel(l, raw?.l ?? 0, now, dtMs), stepChannel(r, raw?.r ?? 0, now, dtMs)];
68
+ }
69
+
70
+ /** The meters appear only for a project with at least one audio clip or group. */
71
+ function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
72
+ return elements.some((el) => isAudioTimelineElement(el) || el.audioGroup !== undefined);
73
+ }
74
+
75
+ /** Scans once per `elements` change, not on every store update (the playhead ticks each frame). */
76
+ export function useProjectHasAudio(): boolean {
77
+ const elements = usePlayerStore((s) => s.elements);
78
+ return useMemo(() => hasProjectAudio(elements), [elements]);
79
+ }
@@ -0,0 +1,14 @@
1
+ import { create } from "zustand";
2
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
3
+
4
+ /** Whether the audio meter strip is shown; persisted, off unless the user showed it. */
5
+ export const useAudioMetersVisible = create<{
6
+ visible: boolean;
7
+ setVisible: (visible: boolean) => void;
8
+ }>((set) => ({
9
+ visible: readStudioUiPreferences().audioMetersVisible ?? false,
10
+ setVisible: (visible) => {
11
+ writeStudioUiPreferences({ audioMetersVisible: visible });
12
+ set({ visible });
13
+ },
14
+ }));