@hyperframes/studio 0.6.29 → 0.6.30

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 (39) hide show
  1. package/dist/assets/index-BWBj8I6Q.css +1 -0
  2. package/dist/assets/index-D790O3az.js +115 -0
  3. package/dist/assets/index-DSLrl2tB.js +531 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +13 -0
  7. package/src/components/StudioErrorBoundary.tsx +68 -0
  8. package/src/components/StudioHeader.tsx +15 -3
  9. package/src/components/editor/PropertyPanel.tsx +4 -1
  10. package/src/components/nle/CompositionBreadcrumb.tsx +12 -2
  11. package/src/components/renders/RenderQueue.tsx +2 -0
  12. package/src/components/renders/useRenderQueue.ts +9 -0
  13. package/src/components/sidebar/LeftSidebar.tsx +2 -0
  14. package/src/contexts/FileManagerContext.tsx +3 -3
  15. package/src/hooks/useDomEditCommits.ts +52 -24
  16. package/src/hooks/useFileManager.ts +15 -13
  17. package/src/hooks/usePanelLayout.ts +11 -1
  18. package/src/hooks/useRenderClipContent.test.ts +50 -0
  19. package/src/hooks/useRenderClipContent.ts +23 -4
  20. package/src/hooks/useServerConnection.ts +11 -1
  21. package/src/main.tsx +22 -1
  22. package/src/player/components/CompositionThumbnail.tsx +10 -44
  23. package/src/player/components/PlayerControls.tsx +16 -3
  24. package/src/player/components/TimelineCanvas.tsx +9 -23
  25. package/src/player/components/TimelineClip.tsx +63 -67
  26. package/src/player/components/timelineTheme.ts +18 -48
  27. package/src/player/hooks/usePlaybackKeyboard.test.ts +55 -0
  28. package/src/player/hooks/usePlaybackKeyboard.ts +15 -0
  29. package/src/player/lib/mediaProbe.ts +20 -5
  30. package/src/styles/studio.css +9 -0
  31. package/src/telemetry/client.test.ts +100 -0
  32. package/src/telemetry/client.ts +145 -0
  33. package/src/telemetry/config.ts +78 -0
  34. package/src/telemetry/events.test.ts +57 -0
  35. package/src/telemetry/events.ts +27 -0
  36. package/src/telemetry/system.ts +48 -0
  37. package/src/utils/studioTelemetry.ts +128 -0
  38. package/dist/assets/index-C-kAqQVb.js +0 -362
  39. package/dist/assets/index-DVpLGNHi.css +0 -1
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useEffect, useState } from "react";
2
2
  import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting";
3
3
  import { useMountEffect } from "./useMountEffect";
4
4
 
@@ -67,5 +67,15 @@ export function useServerConnection(): ServerConnectionState {
67
67
  };
68
68
  });
69
69
 
70
+ // eslint-disable-next-line no-restricted-syntax
71
+ useEffect(() => {
72
+ const onHashChange = () => {
73
+ const next = parseProjectIdFromHash(window.location.hash);
74
+ if (next && next !== projectId) setProjectId(next);
75
+ };
76
+ window.addEventListener("hashchange", onHashChange);
77
+ return () => window.removeEventListener("hashchange", onHashChange);
78
+ }, [projectId]);
79
+
70
80
  return { projectId, resolving, waitingForServer };
71
81
  }
package/src/main.tsx CHANGED
@@ -1,10 +1,31 @@
1
1
  import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { StudioApp } from "./App";
4
+ import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
5
+ import { trackStudioEvent } from "./utils/studioTelemetry";
4
6
  import "./styles/studio.css";
5
7
 
8
+ trackStudioEvent("session_start");
9
+
10
+ window.addEventListener("error", (event) => {
11
+ trackStudioEvent("unhandled_error", {
12
+ error_message: event.message,
13
+ filename: event.filename ?? null,
14
+ lineno: event.lineno ?? null,
15
+ colno: event.colno ?? null,
16
+ });
17
+ });
18
+
19
+ window.addEventListener("unhandledrejection", (event) => {
20
+ trackStudioEvent("unhandled_promise_rejection", {
21
+ error_message: event.reason instanceof Error ? event.reason.message : String(event.reason),
22
+ });
23
+ });
24
+
6
25
  createRoot(document.getElementById("root")!).render(
7
26
  <StrictMode>
8
- <StudioApp />
27
+ <StudioErrorBoundary>
28
+ <StudioApp />
29
+ </StudioErrorBoundary>
9
30
  </StrictMode>,
10
31
  );
@@ -5,7 +5,6 @@ interface CompositionThumbnailProps {
5
5
  previewUrl: string;
6
6
  label: string;
7
7
  labelColor: string;
8
- accentColor?: string;
9
8
  selector?: string;
10
9
  selectorIndex?: number;
11
10
  seekTime?: number;
@@ -16,7 +15,6 @@ interface CompositionThumbnailProps {
16
15
 
17
16
  const CLIP_HEIGHT = 66;
18
17
  const THUMBNAIL_URL_VERSION = "v3";
19
- const COMPOSITION_THUMBNAIL_LABEL_Z_INDEX = 10;
20
18
 
21
19
  export function buildCompositionThumbnailUrl({
22
20
  previewUrl,
@@ -53,7 +51,6 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
53
51
  previewUrl,
54
52
  label,
55
53
  labelColor,
56
- accentColor = "#6B7280",
57
54
  selector,
58
55
  selectorIndex,
59
56
  seekTime = 2,
@@ -110,8 +107,11 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
110
107
  className="hidden"
111
108
  />
112
109
 
113
- {loaded ? (
114
- <div className="absolute inset-0 flex">
110
+ {loaded && (
111
+ <div
112
+ className="absolute inset-0 flex"
113
+ style={{ animation: "hf-thumb-fade 200ms ease-out", mixBlendMode: "lighten" }}
114
+ >
115
115
  {Array.from({ length: frameCount }).map((_, i) => (
116
116
  <div
117
117
  key={i}
@@ -122,59 +122,25 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
122
122
  src={url}
123
123
  alt=""
124
124
  draggable={false}
125
- className="absolute inset-0 h-full w-full object-cover opacity-60"
125
+ className="absolute inset-0 h-full w-full object-cover"
126
+ style={{ opacity: 0.7 }}
126
127
  />
127
128
  </div>
128
129
  ))}
129
130
  </div>
130
- ) : (
131
- <div
132
- className="absolute inset-0 animate-pulse"
133
- style={{
134
- background:
135
- "linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
136
- }}
137
- />
138
131
  )}
139
132
 
140
- <div
141
- className="absolute inset-0"
142
- style={{
143
- background: `linear-gradient(120deg, ${accentColor}2e, transparent 34%), linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08))`,
144
- }}
145
- />
146
-
147
- <div
148
- className="absolute left-2 top-2"
149
- style={{ zIndex: COMPOSITION_THUMBNAIL_LABEL_Z_INDEX }}
150
- >
133
+ <div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
151
134
  <span
152
- className="block max-w-full truncate rounded-md px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none"
135
+ className="block max-w-full truncate text-[10px] font-semibold leading-none"
153
136
  style={{
154
137
  color: labelColor,
155
- background: `${accentColor}2e`,
156
- boxShadow: `inset 0 0 0 1px ${accentColor}40`,
138
+ textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
157
139
  }}
158
140
  >
159
141
  {label}
160
142
  </span>
161
143
  </div>
162
-
163
- <div
164
- className="absolute bottom-0 left-0 right-0 px-1.5 pb-0.5 pt-3"
165
- style={{
166
- zIndex: COMPOSITION_THUMBNAIL_LABEL_Z_INDEX,
167
- background:
168
- "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
169
- }}
170
- >
171
- <span
172
- className="block truncate text-[9px] font-semibold leading-tight"
173
- style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
174
- >
175
- {label}
176
- </span>
177
- </div>
178
144
  </div>
179
145
  );
180
146
  });
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
4
4
  import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
5
5
  import { usePlayerStore, liveTime } from "../store/playerStore";
6
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
6
7
 
7
8
  const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
8
9
  const SEEK_EDGE_SNAP_PX = 8;
@@ -15,6 +16,8 @@ const SHORTCUT_SECTIONS = [
15
16
  { key: "J", label: "Play backward" },
16
17
  { key: "K", label: "Stop" },
17
18
  { key: "L", label: "Play forward" },
19
+ { key: "M", label: "Toggle mute" },
20
+ { key: "⇧L", label: "Toggle loop" },
18
21
  { key: "←/→", label: "Step 1 frame" },
19
22
  { key: "⇧←/⇧→", label: "Step 10 frames" },
20
23
  ],
@@ -335,7 +338,10 @@ export const PlayerControls = memo(function PlayerControls({
335
338
  <button
336
339
  type="button"
337
340
  aria-label={isPlaying ? "Pause" : "Play"}
338
- onClick={onTogglePlay}
341
+ onClick={() => {
342
+ trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
343
+ onTogglePlay();
344
+ }}
339
345
  disabled={controlsDisabled}
340
346
  className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
341
347
  style={{ background: "rgba(255,255,255,0.06)" }}
@@ -461,7 +467,10 @@ export const PlayerControls = memo(function PlayerControls({
461
467
  <button
462
468
  type="button"
463
469
  onClick={() => {
464
- if (!audioAutoMuted) setAudioMuted(!audioMuted);
470
+ if (!audioAutoMuted) {
471
+ trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
472
+ setAudioMuted(!audioMuted);
473
+ }
465
474
  }}
466
475
  disabled={controlsDisabled || audioAutoMuted}
467
476
  title={muteButtonLabel}
@@ -528,6 +537,7 @@ export const PlayerControls = memo(function PlayerControls({
528
537
  <button
529
538
  key={rate}
530
539
  onClick={() => {
540
+ trackStudioEvent("playback", { action: "speed_change", rate });
531
541
  setPlaybackRate(rate);
532
542
  setShowSpeedMenu(false);
533
543
  }}
@@ -553,7 +563,10 @@ export const PlayerControls = memo(function PlayerControls({
553
563
 
554
564
  <button
555
565
  type="button"
556
- onClick={() => setLoopEnabled(!loopEnabled)}
566
+ onClick={() => {
567
+ trackStudioEvent("playback", { action: "loop_toggle", enabled: !loopEnabled });
568
+ setLoopEnabled(!loopEnabled);
569
+ }}
557
570
  disabled={disabled}
558
571
  className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
559
572
  loopEnabled
@@ -10,7 +10,6 @@ import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme"
10
10
  import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
11
11
  import type { TimelineElement } from "../store/playerStore";
12
12
  import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
13
- import { formatTime } from "../lib/time";
14
13
  import type { TrackVisualStyle } from "./timelineIcons";
15
14
 
16
15
  interface TimelineCanvasProps {
@@ -134,28 +133,16 @@ export const TimelineCanvas = memo(function TimelineCanvas({
134
133
  className={
135
134
  renderClipContent
136
135
  ? "absolute inset-0 overflow-hidden"
137
- : "flex flex-col justify-center overflow-hidden flex-1 min-w-0 px-6"
136
+ : "flex items-center overflow-hidden flex-1 min-w-0 px-3 gap-2"
138
137
  }
139
138
  >
140
139
  {renderClipContent?.(element, clipStyle) ?? (
141
- <div className="flex h-full min-h-0 flex-col justify-between py-3">
142
- <span
143
- className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] leading-none"
144
- style={{
145
- color: clipStyle.label,
146
- background: `${clipStyle.accent}26`,
147
- boxShadow: `inset 0 0 0 1px ${clipStyle.accent}33`,
148
- }}
149
- >
150
- {element.tag}
151
- </span>
152
- <span
153
- className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none"
154
- style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.04)" }}
155
- >
156
- {formatTime(element.start)} {"→"} {formatTime(element.start + element.duration)}
157
- </span>
158
- </div>
140
+ <span
141
+ className="truncate text-[10px] font-medium leading-none"
142
+ style={{ color: clipStyle.label }}
143
+ >
144
+ {element.label || element.id || element.tag}
145
+ </span>
159
146
  )}
160
147
  </div>
161
148
  </>
@@ -221,10 +208,9 @@ export const TimelineCanvas = memo(function TimelineCanvas({
221
208
  paddingLeft: 16,
222
209
  color: ts.label,
223
210
  fontSize: 11,
224
- letterSpacing: "0.08em",
211
+ letterSpacing: "0.06em",
225
212
  textTransform: "uppercase",
226
- background: `linear-gradient(90deg, ${ts.accent}14, transparent 28%)`,
227
- boxShadow: `inset 0 0 0 1px ${ts.accent}24`,
213
+ opacity: 0.5,
228
214
  }}
229
215
  >
230
216
  New track
@@ -51,14 +51,14 @@ export const TimelineClip = memo(function TimelineClip({
51
51
  const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
52
52
 
53
53
  const borderColor = isSelected
54
- ? theme.clipBorderActive
54
+ ? trackStyle.accent + "60"
55
55
  : isHovered
56
56
  ? theme.clipBorderHover
57
57
  : theme.clipBorder;
58
58
  const boxShadow = isDragging
59
59
  ? theme.clipShadowDragging
60
60
  : isSelected
61
- ? theme.clipShadowActive
61
+ ? `0 0 0 1px ${trackStyle.accent}40`
62
62
  : isHovered
63
63
  ? theme.clipShadowHover
64
64
  : theme.clipShadow;
@@ -77,20 +77,14 @@ export const TimelineClip = memo(function TimelineClip({
77
77
  top: clipY,
78
78
  bottom: clipY,
79
79
  borderRadius: theme.clipRadius,
80
- background: isSelected
81
- ? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
82
- : `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
83
- backgroundImage:
84
- isComposition && !hasCustomContent
85
- ? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
86
- : undefined,
80
+ background: trackStyle.clip,
87
81
  border: `1px solid ${borderColor}`,
88
82
  boxShadow,
89
- transition:
90
- "border-color 120ms ease-out, box-shadow 140ms ease-out, background 140ms ease-out",
83
+ transition: "border-color 100ms, box-shadow 100ms",
91
84
  zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
92
85
  cursor: capabilities.canMove ? "grab" : "default",
93
86
  transform: isDragging ? "translateY(-1px)" : undefined,
87
+ opacity: isDragging ? 0.92 : 1,
94
88
  }}
95
89
  title={
96
90
  isComposition
@@ -103,78 +97,80 @@ export const TimelineClip = memo(function TimelineClip({
103
97
  onClick={onClick}
104
98
  onDoubleClick={onDoubleClick}
105
99
  >
100
+ {/* Left accent stripe */}
106
101
  <div
107
102
  aria-hidden="true"
108
- role="presentation"
109
- onPointerDown={(e) => onResizeStart?.("start", e)}
110
103
  style={{
111
104
  position: "absolute",
112
105
  left: 0,
113
106
  top: 0,
114
107
  bottom: 0,
115
- width: 18,
116
- opacity: showHandles && capabilities.canTrimStart ? 1 : 0,
117
- pointerEvents: onResizeStart && capabilities.canTrimStart ? "auto" : "none",
118
- zIndex: 4,
119
- transition: "opacity 120ms ease-out",
120
- cursor: "col-resize",
121
- background:
122
- showHandles && capabilities.canTrimStart
123
- ? `linear-gradient(90deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
124
- : "transparent",
108
+ width: 3,
109
+ background: trackStyle.accent,
110
+ opacity: isSelected ? 0.7 : 0.3,
111
+ borderRadius: `${theme.clipRadius} 0 0 ${theme.clipRadius}`,
112
+ zIndex: 2,
113
+ pointerEvents: "none",
125
114
  }}
126
- >
115
+ />
116
+ {/* Left trim handle */}
117
+ {showHandles && capabilities.canTrimStart && (
127
118
  <div
119
+ aria-hidden="true"
120
+ onPointerDown={(e) => onResizeStart?.("start", e)}
128
121
  style={{
129
122
  position: "absolute",
130
- left: 6,
131
- top: 7,
132
- bottom: 7,
133
- width: 3,
134
- borderRadius: 999,
135
- background: theme.handleColor,
136
- boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
137
- opacity: handleOpacity,
138
- pointerEvents: "none",
123
+ left: 0,
124
+ top: 0,
125
+ bottom: 0,
126
+ width: 14,
127
+ cursor: "col-resize",
128
+ zIndex: 4,
139
129
  }}
140
- />
141
- </div>
142
- <div
143
- aria-hidden="true"
144
- role="presentation"
145
- onPointerDown={(e) => onResizeStart?.("end", e)}
146
- style={{
147
- position: "absolute",
148
- right: 0,
149
- top: 0,
150
- bottom: 0,
151
- width: 18,
152
- opacity: showHandles && capabilities.canTrimEnd ? 1 : 0,
153
- pointerEvents: onResizeStart && capabilities.canTrimEnd ? "auto" : "none",
154
- zIndex: 4,
155
- transition: "opacity 120ms ease-out",
156
- cursor: "col-resize",
157
- background:
158
- showHandles && capabilities.canTrimEnd
159
- ? `linear-gradient(270deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
160
- : "transparent",
161
- }}
162
- >
130
+ >
131
+ <div
132
+ style={{
133
+ position: "absolute",
134
+ left: 4,
135
+ top: 6,
136
+ bottom: 6,
137
+ width: 2,
138
+ borderRadius: 1,
139
+ background: trackStyle.accent,
140
+ opacity: handleOpacity * 0.6,
141
+ }}
142
+ />
143
+ </div>
144
+ )}
145
+ {/* Right trim handle */}
146
+ {showHandles && capabilities.canTrimEnd && (
163
147
  <div
148
+ aria-hidden="true"
149
+ onPointerDown={(e) => onResizeStart?.("end", e)}
164
150
  style={{
165
151
  position: "absolute",
166
- right: 6,
167
- top: 7,
168
- bottom: 7,
169
- width: 3,
170
- borderRadius: 999,
171
- background: theme.handleColor,
172
- boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
173
- opacity: handleOpacity,
174
- pointerEvents: "none",
152
+ right: 0,
153
+ top: 0,
154
+ bottom: 0,
155
+ width: 14,
156
+ cursor: "col-resize",
157
+ zIndex: 4,
175
158
  }}
176
- />
177
- </div>
159
+ >
160
+ <div
161
+ style={{
162
+ position: "absolute",
163
+ right: 4,
164
+ top: 6,
165
+ bottom: 6,
166
+ width: 2,
167
+ borderRadius: 1,
168
+ background: trackStyle.accent,
169
+ opacity: handleOpacity * 0.6,
170
+ }}
171
+ />
172
+ </div>
173
+ )}
178
174
  {children}
179
175
  </div>
180
176
  );
@@ -35,33 +35,13 @@ export interface TimelineTheme {
35
35
  clipRadius: string;
36
36
  }
37
37
 
38
- const TIMELINE_TEAL = "#3CE6AC";
39
- const TIMELINE_TEAL_LABEL = "#E9FFF6";
40
- const TIMELINE_TEAL_ICON_BACKGROUND = "rgba(60,230,172,0.12)";
41
-
42
- function createTrackStyle(): TimelineTrackStyle {
43
- return {
44
- clip: TIMELINE_TEAL,
45
- accent: TIMELINE_TEAL,
46
- label: TIMELINE_TEAL_LABEL,
47
- iconBackground: TIMELINE_TEAL_ICON_BACKGROUND,
48
- };
49
- }
50
-
51
- const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
52
- video: createTrackStyle(),
53
- audio: createTrackStyle(),
54
- img: createTrackStyle(),
55
- div: createTrackStyle(),
56
- span: createTrackStyle(),
57
- p: createTrackStyle(),
58
- h1: createTrackStyle(),
59
- section: createTrackStyle(),
60
- sfx: createTrackStyle(),
38
+ const TRACK_STYLE: TimelineTrackStyle = {
39
+ clip: "#1c2028",
40
+ accent: "#3CE6AC",
41
+ label: "#dde1e8",
42
+ iconBackground: "rgba(255,255,255,0.06)",
61
43
  };
62
44
 
63
- const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
64
-
65
45
  export const defaultTimelineTheme: TimelineTheme = {
66
46
  shellBackground: "#0A0A0B",
67
47
  shellBorder: "rgba(255,255,255,0.05)",
@@ -75,33 +55,23 @@ export const defaultTimelineTheme: TimelineTheme = {
75
55
  tickText: "rgba(131,145,168,0.92)",
76
56
  tickMajor: "rgba(255,255,255,0.13)",
77
57
  tickMinor: "rgba(255,255,255,0.08)",
78
- clipBackground: "linear-gradient(180deg, rgba(20,25,34,0.98), rgba(14,18,27,0.98))",
79
- clipBackgroundActive: "linear-gradient(180deg, rgba(24,30,40,0.99), rgba(15,20,29,0.99))",
80
- clipBorder: "rgba(255,255,255,0.07)",
81
- clipBorderHover: "rgba(255,255,255,0.11)",
82
- clipBorderActive: "rgba(255,255,255,0.14)",
83
- clipShadow: "inset 0 1px 0 rgba(255,255,255,0.03), 0 6px 18px rgba(0,0,0,0.18)",
84
- clipShadowHover: "inset 0 1px 0 rgba(255,255,255,0.035), 0 8px 20px rgba(0,0,0,0.2)",
85
- clipShadowActive:
86
- "inset 0 1px 0 rgba(255,255,255,0.04), 0 10px 24px rgba(0,0,0,0.22), 0 0 0 1px rgba(255,255,255,0.035)",
87
- clipShadowDragging:
88
- "inset 0 1px 0 rgba(255,255,255,0.04), 0 18px 36px rgba(0,0,0,0.34), 0 8px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.04)",
89
- handleColor: "rgba(255,255,255,0.11)",
58
+ clipBackground: "#141922",
59
+ clipBackgroundActive: "#181e28",
60
+ clipBorder: "rgba(255,255,255,0.10)",
61
+ clipBorderHover: "rgba(255,255,255,0.18)",
62
+ clipBorderActive: "rgba(255,255,255,0.24)",
63
+ clipShadow: "none",
64
+ clipShadowHover: "0 2px 8px rgba(0,0,0,0.2)",
65
+ clipShadowActive: "0 2px 8px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.04)",
66
+ clipShadowDragging: "0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06)",
67
+ handleColor: "rgba(255,255,255,0.2)",
90
68
  panelResizeSeam: "rgba(255,255,255,0.12)",
91
69
  panelResizeActive: "rgba(255,255,255,0.24)",
92
- clipRadius: "11px 15px 13px 9px / 10px 14px 12px 10px",
70
+ clipRadius: "6px",
93
71
  };
94
72
 
95
- export function getTimelineTrackStyle(tag: string): TimelineTrackStyle {
96
- const normalized = tag.toLowerCase();
97
- if (
98
- normalized.startsWith("h") &&
99
- normalized.length === 2 &&
100
- "123456".includes(normalized[1] ?? "")
101
- ) {
102
- return TRACK_STYLES.h1;
103
- }
104
- return TRACK_STYLES[normalized] ?? DEFAULT_TRACK_STYLE;
73
+ export function getTimelineTrackStyle(_tag: string): TimelineTrackStyle {
74
+ return TRACK_STYLE;
105
75
  }
106
76
 
107
77
  export function getClipHandleOpacity({
@@ -172,3 +172,58 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => {
172
172
  expect(spies.play).toHaveBeenCalledTimes(1);
173
173
  });
174
174
  });
175
+
176
+ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
177
+ it("M toggles audioMuted", () => {
178
+ const { dispatch } = setupHook();
179
+ expect(usePlayerStore.getState().audioMuted).toBe(false);
180
+
181
+ act(() => {
182
+ dispatch(keydown({ code: "KeyM", key: "m" }));
183
+ });
184
+ expect(usePlayerStore.getState().audioMuted).toBe(true);
185
+
186
+ act(() => {
187
+ dispatch(keydown({ code: "KeyM", key: "m" }));
188
+ });
189
+ expect(usePlayerStore.getState().audioMuted).toBe(false);
190
+ });
191
+
192
+ it("M does NOT toggle audioMuted above 1x playback (matches button gating)", () => {
193
+ const { dispatch } = setupHook();
194
+ usePlayerStore.setState({ playbackRate: 2, audioMuted: false });
195
+
196
+ act(() => {
197
+ dispatch(keydown({ code: "KeyM", key: "m" }));
198
+ });
199
+
200
+ expect(usePlayerStore.getState().audioMuted).toBe(false);
201
+ });
202
+
203
+ it("Shift+L toggles loopEnabled without starting forward shuttle", () => {
204
+ const { dispatch, spies } = setupHook();
205
+ expect(usePlayerStore.getState().loopEnabled).toBe(false);
206
+
207
+ act(() => {
208
+ dispatch(keydown({ code: "KeyL", key: "L", shiftKey: true }));
209
+ });
210
+ expect(usePlayerStore.getState().loopEnabled).toBe(true);
211
+ expect(spies.play).not.toHaveBeenCalled();
212
+
213
+ act(() => {
214
+ dispatch(keydown({ code: "KeyL", key: "L", shiftKey: true }));
215
+ });
216
+ expect(usePlayerStore.getState().loopEnabled).toBe(false);
217
+ });
218
+
219
+ it("Plain L still starts forward shuttle (regression guard)", () => {
220
+ const { dispatch, spies } = setupHook();
221
+
222
+ act(() => {
223
+ dispatch(keydown({ code: "KeyL", key: "l" }));
224
+ });
225
+
226
+ expect(spies.play).toHaveBeenCalledTimes(1);
227
+ expect(usePlayerStore.getState().loopEnabled).toBe(false);
228
+ });
229
+ });
@@ -108,6 +108,21 @@ export function usePlaybackKeyboard({
108
108
  return;
109
109
  }
110
110
  if (e.repeat) return;
111
+ if (key === "m") {
112
+ e.preventDefault();
113
+ const state = usePlayerStore.getState();
114
+ // Audio is force-muted above 1x playback — match the mute button's gating.
115
+ if (state.playbackRate <= 1) {
116
+ state.setAudioMuted(!state.audioMuted);
117
+ }
118
+ return;
119
+ }
120
+ if (key === "l" && e.shiftKey) {
121
+ e.preventDefault();
122
+ const state = usePlayerStore.getState();
123
+ state.setLoopEnabled(!state.loopEnabled);
124
+ return;
125
+ }
111
126
  if (key === "k") {
112
127
  e.preventDefault();
113
128
  pause();
@@ -1,5 +1,3 @@
1
- import { Input, UrlSource, ALL_FORMATS } from "mediabunny";
2
-
3
1
  export interface MediaProbeResult {
4
2
  duration: number;
5
3
  width?: number;
@@ -11,6 +9,20 @@ export interface MediaProbeResult {
11
9
  const cache = new Map<string, MediaProbeResult>();
12
10
  const inflight = new Map<string, Promise<MediaProbeResult | null>>();
13
11
 
12
+ let mediabunnyModule: typeof import("mediabunny") | null | false = null;
13
+
14
+ async function loadMediabunny() {
15
+ if (mediabunnyModule === false) return null;
16
+ if (mediabunnyModule) return mediabunnyModule;
17
+ try {
18
+ mediabunnyModule = await import("mediabunny");
19
+ return mediabunnyModule;
20
+ } catch {
21
+ mediabunnyModule = false;
22
+ return null;
23
+ }
24
+ }
25
+
14
26
  function normalizeUrl(url: string): string {
15
27
  try {
16
28
  return new URL(url, window.location.href).href;
@@ -20,9 +32,12 @@ function normalizeUrl(url: string): string {
20
32
  }
21
33
 
22
34
  async function probeOne(url: string): Promise<MediaProbeResult | null> {
23
- const input = new Input({
24
- source: new UrlSource(url),
25
- formats: ALL_FORMATS,
35
+ const mb = await loadMediabunny();
36
+ if (!mb) return null;
37
+
38
+ const input = new mb.Input({
39
+ source: new mb.UrlSource(url),
40
+ formats: mb.ALL_FORMATS,
26
41
  });
27
42
  try {
28
43
  const duration = await input.getDurationFromMetadata();