@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.
- package/dist/assets/index-BWBj8I6Q.css +1 -0
- package/dist/assets/index-D790O3az.js +115 -0
- package/dist/assets/index-DSLrl2tB.js +531 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +13 -0
- package/src/components/StudioErrorBoundary.tsx +68 -0
- package/src/components/StudioHeader.tsx +15 -3
- package/src/components/editor/PropertyPanel.tsx +4 -1
- package/src/components/nle/CompositionBreadcrumb.tsx +12 -2
- package/src/components/renders/RenderQueue.tsx +2 -0
- package/src/components/renders/useRenderQueue.ts +9 -0
- package/src/components/sidebar/LeftSidebar.tsx +2 -0
- package/src/contexts/FileManagerContext.tsx +3 -3
- package/src/hooks/useDomEditCommits.ts +52 -24
- package/src/hooks/useFileManager.ts +15 -13
- package/src/hooks/usePanelLayout.ts +11 -1
- package/src/hooks/useRenderClipContent.test.ts +50 -0
- package/src/hooks/useRenderClipContent.ts +23 -4
- package/src/hooks/useServerConnection.ts +11 -1
- package/src/main.tsx +22 -1
- package/src/player/components/CompositionThumbnail.tsx +10 -44
- package/src/player/components/PlayerControls.tsx +16 -3
- package/src/player/components/TimelineCanvas.tsx +9 -23
- package/src/player/components/TimelineClip.tsx +63 -67
- package/src/player/components/timelineTheme.ts +18 -48
- package/src/player/hooks/usePlaybackKeyboard.test.ts +55 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +15 -0
- package/src/player/lib/mediaProbe.ts +20 -5
- package/src/styles/studio.css +9 -0
- package/src/telemetry/client.test.ts +100 -0
- package/src/telemetry/client.ts +145 -0
- package/src/telemetry/config.ts +78 -0
- package/src/telemetry/events.test.ts +57 -0
- package/src/telemetry/events.ts +27 -0
- package/src/telemetry/system.ts +48 -0
- package/src/utils/studioTelemetry.ts +128 -0
- package/dist/assets/index-C-kAqQVb.js +0 -362
- 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
|
-
<
|
|
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
|
|
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
|
|
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
|
|
135
|
+
className="block max-w-full truncate text-[10px] font-semibold leading-none"
|
|
153
136
|
style={{
|
|
154
137
|
color: labelColor,
|
|
155
|
-
|
|
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={
|
|
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)
|
|
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={() =>
|
|
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
|
|
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
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
211
|
+
letterSpacing: "0.06em",
|
|
225
212
|
textTransform: "uppercase",
|
|
226
|
-
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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:
|
|
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:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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:
|
|
131
|
-
top:
|
|
132
|
-
bottom:
|
|
133
|
-
width:
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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:
|
|
167
|
-
top:
|
|
168
|
-
bottom:
|
|
169
|
-
width:
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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: "
|
|
79
|
-
clipBackgroundActive: "
|
|
80
|
-
clipBorder: "rgba(255,255,255,0.
|
|
81
|
-
clipBorderHover: "rgba(255,255,255,0.
|
|
82
|
-
clipBorderActive: "rgba(255,255,255,0.
|
|
83
|
-
clipShadow: "
|
|
84
|
-
clipShadowHover: "
|
|
85
|
-
clipShadowActive:
|
|
86
|
-
|
|
87
|
-
|
|
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: "
|
|
70
|
+
clipRadius: "6px",
|
|
93
71
|
};
|
|
94
72
|
|
|
95
|
-
export function getTimelineTrackStyle(
|
|
96
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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();
|