@hyperframes/studio 0.8.59 → 0.8.61
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/{hyperframes-player-BBub3ZdI.js → hyperframes-player-nmVkPQza.js} +1 -1
- package/dist/assets/{index-CI7WON-E.js → index-2-HsQB2C.js} +1 -1
- package/dist/assets/{index-D1KNPijK.js → index-BK36b0XY.js} +162 -162
- package/dist/assets/{index-BxZ5Cw5w.js → index-COG0K-CQ.js} +1 -1
- package/dist/assets/{index-DZBHG9TW.css → index-DS0kLbuM.css} +1 -1
- package/dist/index.d.ts +5 -6
- package/dist/index.html +2 -2
- package/dist/index.js +4977 -4273
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -0
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +5 -10
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- package/src/components/icons/AudioMetersIcon.tsx +16 -0
- package/src/components/icons/InspectorIcon.tsx +18 -0
- package/src/components/icons/ReverseGradientIcon.tsx +23 -0
- package/src/components/icons/RippleEditIcon.tsx +19 -0
- package/src/components/nle/AudioMeterStrip.test.tsx +278 -7
- package/src/components/nle/AudioMeterStrip.tsx +264 -47
- package/src/components/ui/Menu.test.tsx +3 -4
- package/src/components/ui/Menu.tsx +18 -5
- package/src/components/ui/Select.tsx +7 -1
- package/src/components/ui/Tooltip.tsx +6 -1
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +56 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +13 -0
- package/src/hooks/useRenderClipContent.test.ts +33 -4
- package/src/hooks/useRenderClipContent.ts +7 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +179 -0
- package/src/hooks/useSdkSession.ts +84 -14
- package/src/hooks/useStudioSdkSessions.ts +8 -1
- package/src/player/components/AudioWaveform.test.tsx +53 -1
- package/src/player/components/AudioWaveform.tsx +124 -65
- package/src/player/components/ImageThumbnail.tsx +3 -5
- package/src/player/components/LayerDisclosureRow.tsx +9 -4
- package/src/player/components/PlayheadIndicator.tsx +3 -3
- package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +18 -15
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +11 -4
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineClipFades.tsx +64 -31
- package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
- package/src/player/components/TimelineEmptyState.tsx +1 -1
- package/src/player/components/TimelineFxButton.tsx +10 -8
- package/src/player/components/TimelineGroupHeader.tsx +18 -9
- package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +14 -0
- package/src/player/components/TimelineLanes.tsx +15 -8
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRuler.tsx +3 -2
- package/src/player/components/TimelineShortcutHint.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +4 -6
- package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
- package/src/player/components/TimelineTransitionBadge.tsx +59 -0
- package/src/player/components/TimelineTransitionOverlays.tsx +30 -0
- package/src/player/components/VideoThumbnail.test.tsx +23 -12
- package/src/player/components/VideoThumbnail.tsx +86 -32
- package/src/player/components/audioClipLink.test.ts +34 -0
- package/src/player/components/audioClipLink.ts +33 -0
- package/src/player/components/audioWaveformPeaks.test.ts +32 -0
- package/src/player/components/audioWaveformPeaks.ts +46 -0
- package/src/player/components/thumbnailUtils.test.ts +17 -0
- package/src/player/components/thumbnailUtils.ts +7 -0
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +18 -11
- package/src/player/components/timelineTransitionSeams.test.ts +48 -0
- package/src/player/components/timelineTransitionSeams.ts +36 -0
- package/src/player/components/timelineZones.test.ts +18 -0
- package/src/player/components/timelineZones.ts +4 -1
- package/src/player/lib/playbackTypes.ts +2 -0
- package/src/player/lib/timelineDOM.test.ts +28 -0
- package/src/player/lib/timelineDOM.ts +24 -2
- package/src/player/lib/timelineIframeHelpers.ts +7 -0
- package/src/player/lib/timelineTransitionMetadata.ts +52 -0
- package/src/player/lib/timelineViewportBudgets.ts +2 -1
- package/src/player/store/timelineElement.ts +1 -0
- package/src/styles/hex-baseline.json +3 -22
- package/src/styles/studio.css +126 -46
- package/src/styles/theme.css +113 -1
- package/src/styles/theme.test.ts +9 -0
- package/src/utils/audioMeterVisibility.ts +2 -2
- package/src/utils/sdkCutover.test.ts +102 -1
- package/src/utils/sdkCutover.ts +16 -21
- package/src/utils/sdkEditTransaction.ts +50 -1
- package/src/utils/studioUiPreferences.ts +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
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";
|
|
5
|
+
import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
|
|
5
6
|
|
|
6
7
|
interface AudioWaveformProps {
|
|
7
8
|
audioUrl: string;
|
|
@@ -13,11 +14,68 @@ interface AudioWaveformProps {
|
|
|
13
14
|
projectId: string;
|
|
14
15
|
sessionEpoch: number;
|
|
15
16
|
priority: ThumbnailPriority;
|
|
17
|
+
/** `data-hidden` or a muted audio group. Greys the pill; the clip stays. */
|
|
18
|
+
muted?: boolean;
|
|
19
|
+
/** Same media file as a video clip. Draws the 1px parent tick. */
|
|
20
|
+
linked?: boolean;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
const BAR_WIDTH = 2;
|
|
19
23
|
const BAR_STEP = 3;
|
|
20
24
|
|
|
25
|
+
type BarGeometry = { x: number; width: number; height: number };
|
|
26
|
+
|
|
27
|
+
function paintWaveformBars(
|
|
28
|
+
context: CanvasRenderingContext2D,
|
|
29
|
+
bars: readonly BarGeometry[],
|
|
30
|
+
height: number,
|
|
31
|
+
waveformBarRgb: string,
|
|
32
|
+
waveformBaselineRgb: string,
|
|
33
|
+
amplitudes: readonly number[],
|
|
34
|
+
) {
|
|
35
|
+
bars.forEach((bar, index) => {
|
|
36
|
+
const amplitude = amplitudes[index] ?? 0;
|
|
37
|
+
context.fillStyle = `rgb(${waveformBaselineRgb})`;
|
|
38
|
+
context.fillRect(bar.x, height - 2, bar.width, 2);
|
|
39
|
+
context.fillStyle = `rgba(${waveformBarRgb},${loudnessToOpacity(amplitude).toFixed(2)})`;
|
|
40
|
+
context.fillRect(bar.x, height - bar.height, bar.width, bar.height);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function drawWaveformCanvas(
|
|
45
|
+
canvas: HTMLCanvasElement,
|
|
46
|
+
peaks: readonly number[],
|
|
47
|
+
muted: boolean,
|
|
48
|
+
trimStartFraction: number,
|
|
49
|
+
trimEndFraction: number,
|
|
50
|
+
) {
|
|
51
|
+
const width = Math.max(1, canvas.clientWidth);
|
|
52
|
+
const height = Math.max(1, canvas.clientHeight);
|
|
53
|
+
const scale = window.devicePixelRatio || 1;
|
|
54
|
+
canvas.width = Math.ceil(width * scale);
|
|
55
|
+
canvas.height = Math.ceil(height * scale);
|
|
56
|
+
const context = canvas.getContext("2d");
|
|
57
|
+
if (!context) return;
|
|
58
|
+
context.scale(scale, scale);
|
|
59
|
+
context.clearRect(0, 0, width, height);
|
|
60
|
+
const amplitudes = decimatePeaks(
|
|
61
|
+
peaks,
|
|
62
|
+
trimStartFraction,
|
|
63
|
+
trimEndFraction,
|
|
64
|
+
Math.max(1, Math.ceil(width / BAR_STEP)),
|
|
65
|
+
);
|
|
66
|
+
const bars = amplitudes.map((amplitude, index) => ({
|
|
67
|
+
x: (index * width) / amplitudes.length,
|
|
68
|
+
width: Math.max(1, width / amplitudes.length),
|
|
69
|
+
height: Math.max(3, amplitude * height),
|
|
70
|
+
}));
|
|
71
|
+
const channelToken = muted ? "--timeline-waveform-muted-rgb" : "--timeline-waveform-bar-rgb";
|
|
72
|
+
const waveformBarRgb = getComputedStyle(canvas).getPropertyValue(channelToken);
|
|
73
|
+
const waveformBaselineRgb = getComputedStyle(canvas).getPropertyValue(
|
|
74
|
+
"--timeline-waveform-baseline-rgb",
|
|
75
|
+
);
|
|
76
|
+
paintWaveformBars(context, bars, height, waveformBarRgb, waveformBaselineRgb, amplitudes);
|
|
77
|
+
}
|
|
78
|
+
|
|
21
79
|
function extractPeaks(channelData: Float32Array, barCount: number): number[] {
|
|
22
80
|
const peaks: number[] = [];
|
|
23
81
|
const samplesPerBar = Math.floor(channelData.length / barCount);
|
|
@@ -92,7 +150,10 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
92
150
|
projectId,
|
|
93
151
|
sessionEpoch,
|
|
94
152
|
priority,
|
|
153
|
+
muted = false,
|
|
154
|
+
linked = false,
|
|
95
155
|
}: AudioWaveformProps) {
|
|
156
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
96
157
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
97
158
|
const observerRef = useRef<ResizeObserver | null>(null);
|
|
98
159
|
const cacheKey = waveformUrl ?? audioUrl;
|
|
@@ -121,29 +182,8 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
121
182
|
const draw = useCallback(() => {
|
|
122
183
|
const canvas = canvasRef.current;
|
|
123
184
|
if (!canvas || !peaks) return;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const scale = window.devicePixelRatio || 1;
|
|
127
|
-
canvas.width = Math.ceil(width * scale);
|
|
128
|
-
canvas.height = Math.ceil(height * scale);
|
|
129
|
-
const context = canvas.getContext("2d");
|
|
130
|
-
if (!context) return;
|
|
131
|
-
context.scale(scale, scale);
|
|
132
|
-
context.clearRect(0, 0, width, height);
|
|
133
|
-
const startFraction = Math.max(0, Math.min(1, trimStartFraction ?? 0));
|
|
134
|
-
const endFraction = Math.max(startFraction, Math.min(1, trimEndFraction ?? 1));
|
|
135
|
-
const start = Math.floor(startFraction * peaks.length);
|
|
136
|
-
const end = Math.max(start + 1, Math.ceil(endFraction * peaks.length));
|
|
137
|
-
const span = end - start;
|
|
138
|
-
const barCount = Math.floor(width / BAR_STEP);
|
|
139
|
-
for (let index = 0; index < barCount; index++) {
|
|
140
|
-
const peakIndex = start + Math.min(span - 1, Math.floor((index / barCount) * span));
|
|
141
|
-
const amplitude = peaks[peakIndex] ?? 0;
|
|
142
|
-
const barHeight = Math.max(2, amplitude * height);
|
|
143
|
-
context.fillStyle = `rgba(75,163,210,${(0.45 + amplitude * 0.4).toFixed(2)})`;
|
|
144
|
-
context.fillRect(index * BAR_STEP, height - barHeight, BAR_WIDTH, barHeight);
|
|
145
|
-
}
|
|
146
|
-
}, [peaks, trimEndFraction, trimStartFraction]);
|
|
185
|
+
drawWaveformCanvas(canvas, peaks, muted, trimStartFraction ?? 0, trimEndFraction ?? 1);
|
|
186
|
+
}, [muted, peaks, trimEndFraction, trimStartFraction]);
|
|
147
187
|
|
|
148
188
|
const setCanvasRef = useCallback(
|
|
149
189
|
(canvas: HTMLCanvasElement | null) => {
|
|
@@ -157,55 +197,74 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
157
197
|
[draw],
|
|
158
198
|
);
|
|
159
199
|
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
const root = document.documentElement;
|
|
202
|
+
const observer = new MutationObserver(draw);
|
|
203
|
+
observer.observe(root, {
|
|
204
|
+
attributes: true,
|
|
205
|
+
attributeFilter: ["class", "data-chrome", "data-theme", "style"],
|
|
206
|
+
});
|
|
207
|
+
return () => observer.disconnect();
|
|
208
|
+
}, [draw]);
|
|
209
|
+
|
|
160
210
|
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
161
211
|
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
const clip = rootRef.current?.closest(".timeline-clip");
|
|
214
|
+
if (!(clip instanceof HTMLElement)) return;
|
|
215
|
+
if (muted) clip.setAttribute("data-audio-muted", "true");
|
|
216
|
+
else clip.removeAttribute("data-audio-muted");
|
|
217
|
+
return () => clip.removeAttribute("data-audio-muted");
|
|
218
|
+
}, [muted]);
|
|
219
|
+
|
|
162
220
|
return (
|
|
163
|
-
<div className="absolute inset-0
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
<div
|
|
171
|
-
className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
|
|
172
|
-
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%)",
|
|
175
|
-
}}
|
|
221
|
+
<div ref={rootRef} className="absolute inset-0">
|
|
222
|
+
{linked ? <span className="timeline-audio-link" aria-hidden="true" /> : null}
|
|
223
|
+
<div className="absolute inset-0 overflow-hidden" style={{ zIndex: 10 }}>
|
|
224
|
+
<canvas
|
|
225
|
+
ref={setCanvasRef}
|
|
226
|
+
className="absolute inset-x-0 bottom-0 w-full"
|
|
227
|
+
style={{ top: 16, height: "calc(100% - 16px)" }}
|
|
176
228
|
/>
|
|
177
|
-
|
|
178
|
-
{/* Degraded state — the decode failed; say so rather than paint a
|
|
179
|
-
waveform the author could edit against. */}
|
|
180
|
-
{snapshot.status === "error" && (
|
|
181
|
-
<div
|
|
182
|
-
className="absolute inset-x-0 flex items-center justify-center gap-1.5"
|
|
183
|
-
style={{ top: 16, bottom: 0 }}
|
|
184
|
-
>
|
|
229
|
+
{snapshot.status === "loading" && (
|
|
185
230
|
<div
|
|
186
|
-
className="absolute inset-x-0"
|
|
231
|
+
className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
|
|
187
232
|
style={{
|
|
188
|
-
|
|
189
|
-
height: 2,
|
|
190
|
-
background:
|
|
191
|
-
"repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
|
|
233
|
+
background: "var(--timeline-thumbnail-shimmer)",
|
|
192
234
|
}}
|
|
193
235
|
/>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
<span
|
|
202
|
-
className="block truncate text-[9px] font-semibold leading-tight"
|
|
203
|
-
style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
|
|
236
|
+
)}
|
|
237
|
+
{/* Degraded state — the decode failed; say so rather than paint a
|
|
238
|
+
waveform the author could edit against. */}
|
|
239
|
+
{snapshot.status === "error" && (
|
|
240
|
+
<div
|
|
241
|
+
className="absolute inset-x-0 flex items-center justify-center gap-1.5"
|
|
242
|
+
style={{ top: 16, bottom: 0 }}
|
|
204
243
|
>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
244
|
+
<div
|
|
245
|
+
className="absolute inset-x-0"
|
|
246
|
+
style={{
|
|
247
|
+
bottom: "20%",
|
|
248
|
+
height: 2,
|
|
249
|
+
background: "var(--timeline-waveform-error)",
|
|
250
|
+
}}
|
|
251
|
+
/>
|
|
252
|
+
<span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
|
|
253
|
+
waveform unavailable
|
|
254
|
+
</span>
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
{label && (
|
|
258
|
+
<div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
|
|
259
|
+
<span
|
|
260
|
+
className="block truncate text-[9px] font-semibold leading-tight"
|
|
261
|
+
style={{ color: labelColor, textShadow: "var(--timeline-waveform-label-shadow)" }}
|
|
262
|
+
>
|
|
263
|
+
{label}
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
209
268
|
</div>
|
|
210
269
|
);
|
|
211
270
|
});
|
|
@@ -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: "
|
|
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-[
|
|
41
|
-
isExpanded
|
|
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: "
|
|
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
|
|
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(--
|
|
30
|
-
glowColor = "
|
|
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
|
|
96
|
+
boxShadow: `0 1px 3px var(--timeline-playhead-shadow), 0 0 5px ${glowColor}`,
|
|
97
97
|
transform: "rotate(45deg)",
|
|
98
98
|
}}
|
|
99
99
|
/>
|
|
@@ -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("
|
|
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 ? "
|
|
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-
|
|
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="
|
|
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="
|
|
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-
|
|
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}
|
|
@@ -94,7 +94,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
94
94
|
left: props.contentOrigin + gap.start * props.pps,
|
|
95
95
|
width: Math.max((gap.end - gap.start) * props.pps, 2),
|
|
96
96
|
height: TRACK_H - CLIP_Y * 2,
|
|
97
|
-
background: loud ? "
|
|
97
|
+
background: loud ? "var(--timeline-accent-soft)" : "var(--timeline-accent-faint)",
|
|
98
98
|
borderRadius: 4,
|
|
99
99
|
zIndex: 25,
|
|
100
100
|
}}
|
|
@@ -112,8 +112,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
112
112
|
left: props.contentOrigin + draggedClip.previewStart * props.pps,
|
|
113
113
|
width: Math.max(draggedClip.element.duration * props.pps, 4),
|
|
114
114
|
height: draggedClipHeight,
|
|
115
|
-
border: "1px solid color-mix(in srgb, var(--
|
|
116
|
-
background: "color-mix(in srgb, var(--
|
|
115
|
+
border: "1px solid color-mix(in srgb, var(--timeline-accent) 55%, transparent)",
|
|
116
|
+
background: "color-mix(in srgb, var(--timeline-accent) 12%, transparent)",
|
|
117
117
|
borderRadius: 4,
|
|
118
118
|
zIndex: 30,
|
|
119
119
|
}}
|
|
@@ -132,8 +132,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
132
132
|
left: props.contentOrigin + props.dropPreview.start * props.pps,
|
|
133
133
|
width: DROP_PREVIEW_SECONDS * props.pps,
|
|
134
134
|
height: TRACK_H - CLIP_Y * 2,
|
|
135
|
-
border: "1px solid color-mix(in srgb, var(--
|
|
136
|
-
background: "color-mix(in srgb, var(--
|
|
135
|
+
border: "1px solid color-mix(in srgb, var(--timeline-accent) 55%, transparent)",
|
|
136
|
+
background: "color-mix(in srgb, var(--timeline-accent) 12%, transparent)",
|
|
137
137
|
borderRadius: 4,
|
|
138
138
|
zIndex: 30,
|
|
139
139
|
}}
|
|
@@ -151,8 +151,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
151
151
|
left: props.contentOrigin,
|
|
152
152
|
width: props.trackContentWidth,
|
|
153
153
|
height: 1,
|
|
154
|
-
background: "
|
|
155
|
-
boxShadow: "0 0 3px
|
|
154
|
+
background: "var(--timeline-accent)",
|
|
155
|
+
boxShadow: "0 0 3px var(--timeline-accent-glow)",
|
|
156
156
|
zIndex: 55,
|
|
157
157
|
}}
|
|
158
158
|
/>
|
|
@@ -167,11 +167,14 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
167
167
|
top: RULER_H,
|
|
168
168
|
bottom: 0,
|
|
169
169
|
width: 1,
|
|
170
|
-
background:
|
|
170
|
+
background:
|
|
171
|
+
snapGuide.type === "playhead"
|
|
172
|
+
? "var(--timeline-accent)"
|
|
173
|
+
: "var(--timeline-snap-guide)",
|
|
171
174
|
boxShadow:
|
|
172
175
|
snapGuide.type === "playhead"
|
|
173
|
-
? "0 0 6px
|
|
174
|
-
: "0 0 6px
|
|
176
|
+
? "0 0 6px var(--timeline-accent-glow)"
|
|
177
|
+
: "0 0 6px var(--timeline-text-dim)",
|
|
175
178
|
zIndex: 60,
|
|
176
179
|
}}
|
|
177
180
|
/>
|
|
@@ -201,8 +204,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
201
204
|
top: props.marqueeRect.top,
|
|
202
205
|
width: props.marqueeRect.width,
|
|
203
206
|
height: props.marqueeRect.height,
|
|
204
|
-
background: "
|
|
205
|
-
border: "1px dashed
|
|
207
|
+
background: "var(--timeline-accent-fill)",
|
|
208
|
+
border: "1px dashed var(--timeline-accent-border)",
|
|
206
209
|
borderRadius: 2,
|
|
207
210
|
zIndex: 70,
|
|
208
211
|
}}
|
|
@@ -220,9 +223,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
220
223
|
width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps,
|
|
221
224
|
top: RULER_H,
|
|
222
225
|
bottom: 0,
|
|
223
|
-
backgroundColor: "
|
|
224
|
-
borderLeft: "1px solid
|
|
225
|
-
borderRight: "1px solid
|
|
226
|
+
backgroundColor: "var(--timeline-info-bg)",
|
|
227
|
+
borderLeft: "1px solid var(--timeline-info-border)",
|
|
228
|
+
borderRight: "1px solid var(--timeline-info-border)",
|
|
226
229
|
zIndex: 50,
|
|
227
230
|
}}
|
|
228
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:
|
|
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("
|
|
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:
|
|
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")
|
|
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: {
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { memo, type CSSProperties, type ReactNode } from "react";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
clipWidthLadder,
|
|
5
|
+
defaultTimelineTheme,
|
|
6
|
+
getClipHandleOpacity,
|
|
7
|
+
type TimelineTheme,
|
|
8
|
+
} from "./timelineTheme";
|
|
4
9
|
import type { TimelineEditCapabilities } from "./timelineEditing";
|
|
5
10
|
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
6
11
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
@@ -60,9 +65,10 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
60
65
|
const widthPx = Math.max(el.duration * pps, 4);
|
|
61
66
|
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
|
62
67
|
const displayLabel = el.label || el.id || el.tag;
|
|
68
|
+
const ladder = clipWidthLadder(widthPx);
|
|
63
69
|
const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected);
|
|
64
|
-
const showLabel =
|
|
65
|
-
const showDefaultText = !hasCustomContent &&
|
|
70
|
+
const showLabel = ladder === "labeled";
|
|
71
|
+
const showDefaultText = !hasCustomContent && ladder === "labeled";
|
|
66
72
|
const startLabel = el.start.toFixed(1);
|
|
67
73
|
const endLabel = (el.start + el.duration).toFixed(1);
|
|
68
74
|
const themeVariables = {
|
|
@@ -92,7 +98,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
92
98
|
width: widthPx,
|
|
93
99
|
top: clipY,
|
|
94
100
|
...(clipHeight === undefined ? { bottom: clipY } : { height: clipHeight }),
|
|
95
|
-
borderRadius: theme.clipRadius,
|
|
101
|
+
borderRadius: isAudioClip ? theme.audioClipRadius : theme.clipRadius,
|
|
96
102
|
...themeVariables,
|
|
97
103
|
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
|
98
104
|
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
|
|
@@ -114,6 +120,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
114
120
|
data-clip-start={el.start}
|
|
115
121
|
data-clip-end={el.start + el.duration}
|
|
116
122
|
data-clip-hidden={el.hidden ? "true" : undefined}
|
|
123
|
+
data-ladder={ladder}
|
|
117
124
|
data-active={isActive ? "" : undefined}
|
|
118
125
|
aria-hidden={isGestureActor ? "true" : undefined}
|
|
119
126
|
tabIndex={isGestureActor ? undefined : tabIndex}
|
|
@@ -258,7 +258,9 @@ describe("TimelineClipDiamonds", () => {
|
|
|
258
258
|
const selectedDiamond = host.querySelector<HTMLButtonElement>('button[title="60%"]');
|
|
259
259
|
expect(playheadDiamond?.dataset.keyframeAtPlayhead).toBe("true");
|
|
260
260
|
expect(playheadDiamond?.dataset.keyframeSelected).toBe("false");
|
|
261
|
-
expect(playheadDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe(
|
|
261
|
+
expect(playheadDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe(
|
|
262
|
+
"var(--timeline-diamond-muted)",
|
|
263
|
+
);
|
|
262
264
|
expect(playheadDiamond?.querySelector('path[stroke="#4ba3d2"]')).not.toBeNull();
|
|
263
265
|
expect(selectedDiamond?.dataset.keyframeAtPlayhead).toBe("false");
|
|
264
266
|
expect(selectedDiamond?.dataset.keyframeSelected).toBe("true");
|
|
@@ -225,7 +225,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
225
225
|
isSelected && halfFramePct > 0
|
|
226
226
|
? nearestKeyframeWithin(sorted, currentPercentage, halfFramePct)
|
|
227
227
|
: null;
|
|
228
|
-
const baseColor = isSelected ? accentColor : "
|
|
228
|
+
const baseColor = isSelected ? accentColor : "var(--timeline-diamond-muted)";
|
|
229
229
|
const baseOpacity = isSelected ? 0.4 : 0.25;
|
|
230
230
|
const canDrag = isSelected && !!onMoveKeyframe;
|
|
231
231
|
|
|
@@ -280,7 +280,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
280
280
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
|
281
281
|
const atPlayhead = kf === playheadKeyframe;
|
|
282
282
|
const isHighlighted = isKfSelected || atPlayhead;
|
|
283
|
-
const color = isKfSelected ? accentColor : "
|
|
283
|
+
const color = isKfSelected ? accentColor : "var(--timeline-diamond-muted)";
|
|
284
284
|
|
|
285
285
|
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
286
286
|
if (e.button !== 0) return;
|