@hyperframes/studio 0.8.60 → 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-Di7GyCOP.js → hyperframes-player-nmVkPQza.js} +1 -1
- package/dist/assets/{index-jAjhQTrB.js → index-2-HsQB2C.js} +1 -1
- package/dist/assets/{index-Dpq2lkOm.js → index-BK36b0XY.js} +160 -160
- package/dist/assets/{index-Dx_pt9m4.js → index-COG0K-CQ.js} +1 -1
- package/dist/assets/{index-CBKb_0TI.css → index-DS0kLbuM.css} +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3272 -2889
- 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/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 +113 -63
- package/src/player/components/TimelineClipFades.tsx +64 -31
- package/src/player/components/TimelineLanes.tsx +15 -8
- 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 +83 -27
- 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/timelineTheme.ts +6 -0
- 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 +4 -4
- package/src/styles/studio.css +84 -0
- package/src/styles/theme.css +10 -2
- package/src/utils/sdkCutover.test.ts +102 -1
- package/src/utils/sdkCutover.ts +16 -21
- package/src/utils/sdkEditTransaction.ts +50 -1
|
@@ -11,7 +11,7 @@ vi.mock("../../hooks/useThumbnailLease", () => ({
|
|
|
11
11
|
useThumbnailLease: leaseSpy,
|
|
12
12
|
}));
|
|
13
13
|
|
|
14
|
-
import { AudioWaveform } from "./AudioWaveform";
|
|
14
|
+
import { AudioWaveform, drawWaveformCanvas } from "./AudioWaveform";
|
|
15
15
|
|
|
16
16
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
17
17
|
|
|
@@ -21,6 +21,30 @@ afterEach(() => {
|
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
describe("AudioWaveform", () => {
|
|
24
|
+
it("paints a baseline and peak bar for every mapped waveform bin", () => {
|
|
25
|
+
const fillRect = vi.fn();
|
|
26
|
+
const context = {
|
|
27
|
+
scale: vi.fn(),
|
|
28
|
+
clearRect: vi.fn(),
|
|
29
|
+
fillRect,
|
|
30
|
+
fillStyle: "",
|
|
31
|
+
} as unknown as CanvasRenderingContext2D;
|
|
32
|
+
const canvas = document.createElement("canvas");
|
|
33
|
+
Object.defineProperties(canvas, {
|
|
34
|
+
clientWidth: { value: 6 },
|
|
35
|
+
clientHeight: { value: 20 },
|
|
36
|
+
});
|
|
37
|
+
vi.spyOn(canvas, "getContext").mockReturnValue(context);
|
|
38
|
+
drawWaveformCanvas(canvas, [0.25, 1], false, 0, 1);
|
|
39
|
+
expect(fillRect).toHaveBeenCalledTimes(4);
|
|
40
|
+
expect(fillRect.mock.calls.map(([x, y, width, height]) => [x, y, width, height])).toEqual([
|
|
41
|
+
[0, 18, 3, 2],
|
|
42
|
+
[0, 15, 3, 5],
|
|
43
|
+
[3, 18, 3, 2],
|
|
44
|
+
[3, 0, 3, 20],
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
24
48
|
it("leases waveform decoding with the clip's project, session, and viewport priority", () => {
|
|
25
49
|
const host = document.createElement("div");
|
|
26
50
|
document.body.append(host);
|
|
@@ -50,4 +74,32 @@ describe("AudioWaveform", () => {
|
|
|
50
74
|
|
|
51
75
|
act(() => root.unmount());
|
|
52
76
|
});
|
|
77
|
+
|
|
78
|
+
it("greys the clip in place when muted and draws the parent tick when linked", () => {
|
|
79
|
+
const host = document.createElement("div");
|
|
80
|
+
host.className = "timeline-clip is-audio";
|
|
81
|
+
document.body.append(host);
|
|
82
|
+
const root = createRoot(host);
|
|
83
|
+
|
|
84
|
+
act(() => {
|
|
85
|
+
root.render(
|
|
86
|
+
<AudioWaveform
|
|
87
|
+
audioUrl="/media/voice.wav"
|
|
88
|
+
label=""
|
|
89
|
+
labelColor="#fff"
|
|
90
|
+
projectId="project-a"
|
|
91
|
+
sessionEpoch={1}
|
|
92
|
+
priority="visible"
|
|
93
|
+
muted
|
|
94
|
+
linked
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(host.getAttribute("data-audio-muted")).toBe("true");
|
|
100
|
+
expect(host.querySelector(".timeline-audio-link")).not.toBeNull();
|
|
101
|
+
|
|
102
|
+
act(() => root.unmount());
|
|
103
|
+
expect(host.hasAttribute("data-audio-muted")).toBe(false);
|
|
104
|
+
});
|
|
53
105
|
});
|
|
@@ -2,6 +2,7 @@ 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,30 +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
|
-
const waveformBarRgb = getComputedStyle(canvas).getPropertyValue("--timeline-waveform-bar-rgb");
|
|
140
|
-
for (let index = 0; index < barCount; index++) {
|
|
141
|
-
const peakIndex = start + Math.min(span - 1, Math.floor((index / barCount) * span));
|
|
142
|
-
const amplitude = peaks[peakIndex] ?? 0;
|
|
143
|
-
const barHeight = Math.max(2, amplitude * height);
|
|
144
|
-
context.fillStyle = `rgba(${waveformBarRgb},${(0.45 + amplitude * 0.4).toFixed(2)})`;
|
|
145
|
-
context.fillRect(index * BAR_STEP, height - barHeight, BAR_WIDTH, barHeight);
|
|
146
|
-
}
|
|
147
|
-
}, [peaks, trimEndFraction, trimStartFraction]);
|
|
185
|
+
drawWaveformCanvas(canvas, peaks, muted, trimStartFraction ?? 0, trimEndFraction ?? 1);
|
|
186
|
+
}, [muted, peaks, trimEndFraction, trimStartFraction]);
|
|
148
187
|
|
|
149
188
|
const setCanvasRef = useCallback(
|
|
150
189
|
(canvas: HTMLCanvasElement | null) => {
|
|
@@ -170,51 +209,62 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
170
209
|
|
|
171
210
|
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
172
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
|
+
|
|
173
220
|
return (
|
|
174
|
-
<div className="absolute inset-0
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
<div
|
|
182
|
-
className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
|
|
183
|
-
style={{
|
|
184
|
-
background: "var(--timeline-thumbnail-shimmer)",
|
|
185
|
-
}}
|
|
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)" }}
|
|
186
228
|
/>
|
|
187
|
-
|
|
188
|
-
{/* Degraded state — the decode failed; say so rather than paint a
|
|
189
|
-
waveform the author could edit against. */}
|
|
190
|
-
{snapshot.status === "error" && (
|
|
191
|
-
<div
|
|
192
|
-
className="absolute inset-x-0 flex items-center justify-center gap-1.5"
|
|
193
|
-
style={{ top: 16, bottom: 0 }}
|
|
194
|
-
>
|
|
229
|
+
{snapshot.status === "loading" && (
|
|
195
230
|
<div
|
|
196
|
-
className="absolute inset-x-0"
|
|
231
|
+
className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
|
|
197
232
|
style={{
|
|
198
|
-
|
|
199
|
-
height: 2,
|
|
200
|
-
background: "var(--timeline-waveform-error)",
|
|
233
|
+
background: "var(--timeline-thumbnail-shimmer)",
|
|
201
234
|
}}
|
|
202
235
|
/>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
<span
|
|
211
|
-
className="block truncate text-[9px] font-semibold leading-tight"
|
|
212
|
-
style={{ color: labelColor, textShadow: "var(--timeline-waveform-label-shadow)" }}
|
|
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 }}
|
|
213
243
|
>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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>
|
|
218
268
|
</div>
|
|
219
269
|
);
|
|
220
270
|
});
|
|
@@ -192,13 +192,14 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
192
192
|
viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
|
|
193
193
|
preserveAspectRatio="none"
|
|
194
194
|
>
|
|
195
|
-
{/*
|
|
195
|
+
{/* Keep the #4250 wedge geometry, but let the real waveform read through it. */}
|
|
196
196
|
{showIn && (
|
|
197
197
|
<>
|
|
198
198
|
<polygon
|
|
199
199
|
data-testid="clip-fade-in"
|
|
200
200
|
points={`0,0 ${inPx},0 0,100`}
|
|
201
201
|
fill="var(--timeline-fade-shade)"
|
|
202
|
+
fillOpacity={0.35}
|
|
202
203
|
/>
|
|
203
204
|
<line
|
|
204
205
|
x1={0}
|
|
@@ -218,6 +219,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
218
219
|
data-testid="clip-fade-out"
|
|
219
220
|
points={`${widthPx - outPx},0 ${widthPx},0 ${widthPx},100`}
|
|
220
221
|
fill="var(--timeline-fade-shade)"
|
|
222
|
+
fillOpacity={0.35}
|
|
221
223
|
/>
|
|
222
224
|
<line
|
|
223
225
|
x1={widthPx - outPx}
|
|
@@ -235,54 +237,85 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
235
237
|
)}
|
|
236
238
|
{canEdit && (
|
|
237
239
|
<>
|
|
238
|
-
<
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
aria-valuemin={0}
|
|
243
|
-
aria-valuemax={Math.max(0, el.duration - fades.fadeOut)}
|
|
244
|
-
aria-valuenow={fades.fadeIn}
|
|
245
|
-
aria-valuetext={`${formatFadeSeconds(fades.fadeIn)}s`}
|
|
246
|
-
data-testid="clip-fade-handle-in"
|
|
247
|
-
title={`Fade in: ${formatFadeSeconds(fades.fadeIn)}s — drag to change`}
|
|
240
|
+
<FadeHandle
|
|
241
|
+
direction="in"
|
|
242
|
+
value={fades.fadeIn}
|
|
243
|
+
max={Math.max(0, el.duration - fades.fadeOut)}
|
|
248
244
|
style={handleStyle(inPx)}
|
|
245
|
+
dragging={dragging === "in"}
|
|
249
246
|
onPointerDown={onHandlePointerDown("in")}
|
|
250
247
|
onPointerMove={onHandlePointerMove}
|
|
251
248
|
onPointerUp={(e) => finish(e, false)}
|
|
252
249
|
onPointerCancel={(e) => finish(e, true)}
|
|
253
250
|
onKeyDown={onHandleKeyDown}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
<div
|
|
260
|
-
role="slider"
|
|
261
|
-
tabIndex={-1}
|
|
262
|
-
aria-label="Fade out"
|
|
263
|
-
aria-valuemin={0}
|
|
264
|
-
aria-valuemax={Math.max(0, el.duration - fades.fadeIn)}
|
|
265
|
-
aria-valuenow={fades.fadeOut}
|
|
266
|
-
aria-valuetext={`${formatFadeSeconds(fades.fadeOut)}s`}
|
|
267
|
-
data-testid="clip-fade-handle-out"
|
|
268
|
-
title={`Fade out: ${formatFadeSeconds(fades.fadeOut)}s — drag to change`}
|
|
251
|
+
/>
|
|
252
|
+
<FadeHandle
|
|
253
|
+
direction="out"
|
|
254
|
+
value={fades.fadeOut}
|
|
255
|
+
max={Math.max(0, el.duration - fades.fadeIn)}
|
|
269
256
|
style={handleStyle(widthPx - outPx)}
|
|
257
|
+
dragging={dragging === "out"}
|
|
270
258
|
onPointerDown={onHandlePointerDown("out")}
|
|
271
259
|
onPointerMove={onHandlePointerMove}
|
|
272
260
|
onPointerUp={(e) => finish(e, false)}
|
|
273
261
|
onPointerCancel={(e) => finish(e, true)}
|
|
274
262
|
onKeyDown={onHandleKeyDown}
|
|
275
|
-
|
|
276
|
-
onDoubleClick={(e) => e.stopPropagation()}
|
|
277
|
-
>
|
|
278
|
-
<FadeDot active={dragging === "out"} />
|
|
279
|
-
</div>
|
|
263
|
+
/>
|
|
280
264
|
</>
|
|
281
265
|
)}
|
|
282
266
|
</>
|
|
283
267
|
);
|
|
284
268
|
}
|
|
285
269
|
|
|
270
|
+
function FadeHandle({
|
|
271
|
+
direction,
|
|
272
|
+
value,
|
|
273
|
+
max,
|
|
274
|
+
style,
|
|
275
|
+
dragging,
|
|
276
|
+
onPointerDown,
|
|
277
|
+
onPointerMove,
|
|
278
|
+
onPointerUp,
|
|
279
|
+
onPointerCancel,
|
|
280
|
+
onKeyDown,
|
|
281
|
+
}: {
|
|
282
|
+
direction: "in" | "out";
|
|
283
|
+
value: number;
|
|
284
|
+
max: number;
|
|
285
|
+
style: CSSProperties;
|
|
286
|
+
dragging: boolean;
|
|
287
|
+
onPointerDown: (event: PointerEvent<HTMLDivElement>) => void;
|
|
288
|
+
onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
|
|
289
|
+
onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
|
|
290
|
+
onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
|
|
291
|
+
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
|
|
292
|
+
}) {
|
|
293
|
+
const label = direction === "in" ? "Fade in" : "Fade out";
|
|
294
|
+
return (
|
|
295
|
+
<div
|
|
296
|
+
role="slider"
|
|
297
|
+
tabIndex={-1}
|
|
298
|
+
aria-label={label}
|
|
299
|
+
aria-valuemin={0}
|
|
300
|
+
aria-valuemax={max}
|
|
301
|
+
aria-valuenow={value}
|
|
302
|
+
aria-valuetext={`${formatFadeSeconds(value)}s`}
|
|
303
|
+
data-testid={`clip-fade-handle-${direction}`}
|
|
304
|
+
title={`${label}: ${formatFadeSeconds(value)}s — drag to change`}
|
|
305
|
+
style={style}
|
|
306
|
+
onPointerDown={onPointerDown}
|
|
307
|
+
onPointerMove={onPointerMove}
|
|
308
|
+
onPointerUp={onPointerUp}
|
|
309
|
+
onPointerCancel={onPointerCancel}
|
|
310
|
+
onKeyDown={onKeyDown}
|
|
311
|
+
onClick={(e) => e.stopPropagation()}
|
|
312
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
313
|
+
>
|
|
314
|
+
<FadeDot active={dragging} />
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
286
319
|
function FadeDot({ active }: { active: boolean }) {
|
|
287
320
|
return (
|
|
288
321
|
<span
|
|
@@ -32,6 +32,8 @@ import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
|
32
32
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
33
33
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
34
34
|
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
|
35
|
+
import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
|
|
36
|
+
import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
|
|
35
37
|
|
|
36
38
|
export function TimelineLanes({
|
|
37
39
|
pps,
|
|
@@ -104,13 +106,15 @@ export function TimelineLanes({
|
|
|
104
106
|
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
|
|
105
107
|
useTimelineGroupDisclosure();
|
|
106
108
|
const automationLanes = useAutomationLanes();
|
|
109
|
+
const allTransitionElements = useMemo(
|
|
110
|
+
() => tracks.flatMap(([, elements]) => elements.map(getPreviewElement)),
|
|
111
|
+
[getPreviewElement, tracks],
|
|
112
|
+
);
|
|
107
113
|
// A group's automation clock is COMPOSITION time (groups doc §1.3), so its
|
|
108
114
|
// synthetic lane element spans the whole composition rather than a clip.
|
|
109
115
|
const compositionDuration = usePlayerStore((s) => s.duration);
|
|
110
116
|
useAutomationSelectionKeyboard({ lanes: automationLanes });
|
|
111
117
|
const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
|
|
112
|
-
// Which tracks are group MEMBERS, so their headers can render the level-2
|
|
113
|
-
// nesting their `aria-level` already reports.
|
|
114
118
|
const groupMemberTracks = useMemo(
|
|
115
119
|
() => new Set(groups.flatMap((group) => group.memberTracks)),
|
|
116
120
|
[groups],
|
|
@@ -197,11 +201,7 @@ export function TimelineLanes({
|
|
|
197
201
|
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
|
198
202
|
const isPendingTrack =
|
|
199
203
|
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
200
|
-
// All lanes use the same uniform color — no alternating stripes.
|
|
201
204
|
const rowBackground = theme.rowBackground;
|
|
202
|
-
// The beat-dot strip occupies the top of this track's lane (active track,
|
|
203
|
-
// or the music track when nothing is selected). When shown, keyframe
|
|
204
|
-
// diamonds shrink + drop to the bottom half so they don't collide with it.
|
|
205
205
|
const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
|
|
206
206
|
selectedElementId,
|
|
207
207
|
isMusicTrack,
|
|
@@ -223,9 +223,10 @@ export function TimelineLanes({
|
|
|
223
223
|
// property lanes are showing. Undefined means "fill the row", which is
|
|
224
224
|
// right only while it is collapsed and the row is nothing but bar.
|
|
225
225
|
const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
|
|
226
|
-
// The clips whose envelopes this row draws, at their dragged positions.
|
|
227
|
-
// Once per row, not once per clip in the map below.
|
|
228
226
|
const automationElements = els.map(getPreviewElement);
|
|
227
|
+
const transitionSeams = deriveTimelineTransitionSeams(allTransitionElements).filter(
|
|
228
|
+
(seam) => seam.incoming.track === trackNum,
|
|
229
|
+
);
|
|
229
230
|
// Minted here because this is the only place that sees BOTH ends of
|
|
230
231
|
// the disclosure: the caret in the sticky header and the diamond lanes
|
|
231
232
|
// on the canvas. Keyed by display row, not by `trackNum`, which is a
|
|
@@ -553,6 +554,12 @@ export function TimelineLanes({
|
|
|
553
554
|
);
|
|
554
555
|
})
|
|
555
556
|
}
|
|
557
|
+
<TimelineTransitionOverlays
|
|
558
|
+
seams={transitionSeams}
|
|
559
|
+
pixelsPerSecond={pps}
|
|
560
|
+
rowHeight={rowHeight}
|
|
561
|
+
clipBarHeight={clipBarHeight}
|
|
562
|
+
/>
|
|
556
563
|
{/* The automation lanes belong to the ROW, so they are mounted
|
|
557
564
|
here rather than under the active clip's property lanes.
|
|
558
565
|
Hanging off that clip meant selecting a sibling moved the
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
interface TimelineTransitionBadgeProps {
|
|
4
|
+
centerPx: number;
|
|
5
|
+
top: number;
|
|
6
|
+
widthPx: number;
|
|
7
|
+
outgoingSrc?: string;
|
|
8
|
+
incomingSrc?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function TimelineTransitionBadge({
|
|
12
|
+
centerPx,
|
|
13
|
+
top,
|
|
14
|
+
widthPx,
|
|
15
|
+
outgoingSrc,
|
|
16
|
+
incomingSrc,
|
|
17
|
+
}: TimelineTransitionBadgeProps) {
|
|
18
|
+
const style = {
|
|
19
|
+
left: centerPx,
|
|
20
|
+
top,
|
|
21
|
+
"--timeline-transition-zone-width": `${widthPx}px`,
|
|
22
|
+
} as CSSProperties;
|
|
23
|
+
return (
|
|
24
|
+
<div className="timeline-transition" style={style} aria-hidden="true">
|
|
25
|
+
<div className="timeline-transition__zone">
|
|
26
|
+
<div
|
|
27
|
+
className="timeline-transition__image timeline-transition__image--out"
|
|
28
|
+
style={outgoingSrc ? { backgroundImage: `url("${outgoingSrc}")` } : undefined}
|
|
29
|
+
/>
|
|
30
|
+
<div
|
|
31
|
+
className="timeline-transition__image timeline-transition__image--in"
|
|
32
|
+
style={incomingSrc ? { backgroundImage: `url("${incomingSrc}")` } : undefined}
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="timeline-transition__badge">
|
|
36
|
+
<svg viewBox="0 0 20 20" role="presentation">
|
|
37
|
+
<defs>
|
|
38
|
+
<filter id="timeline-transition-glyph-blur">
|
|
39
|
+
<feGaussianBlur stdDeviation="0.55" />
|
|
40
|
+
</filter>
|
|
41
|
+
</defs>
|
|
42
|
+
<path
|
|
43
|
+
className="timeline-transition__frame timeline-transition__frame--out"
|
|
44
|
+
d="M3 5h8v10H3z"
|
|
45
|
+
/>
|
|
46
|
+
<path
|
|
47
|
+
className="timeline-transition__frame timeline-transition__frame--in"
|
|
48
|
+
d="M9 5h8v10H9z"
|
|
49
|
+
/>
|
|
50
|
+
<path
|
|
51
|
+
className="timeline-transition__intersection"
|
|
52
|
+
d="M9 5h2v10H9z"
|
|
53
|
+
filter="url(#timeline-transition-glyph-blur)"
|
|
54
|
+
/>
|
|
55
|
+
</svg>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CLIP_Y } from "./timelineLayout";
|
|
2
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
3
|
+
import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
|
|
4
|
+
import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
|
|
5
|
+
|
|
6
|
+
interface TimelineTransitionOverlaysProps {
|
|
7
|
+
seams: readonly TimelineTransitionSeam[];
|
|
8
|
+
pixelsPerSecond: number;
|
|
9
|
+
rowHeight: number;
|
|
10
|
+
clipBarHeight?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function TimelineTransitionOverlays({
|
|
14
|
+
seams,
|
|
15
|
+
pixelsPerSecond,
|
|
16
|
+
rowHeight,
|
|
17
|
+
clipBarHeight,
|
|
18
|
+
}: TimelineTransitionOverlaysProps) {
|
|
19
|
+
const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
|
|
20
|
+
return seams.map((seam) => (
|
|
21
|
+
<TimelineTransitionBadge
|
|
22
|
+
key={`${getTimelineElementIdentity(seam.outgoing)}-${getTimelineElementIdentity(seam.incoming)}`}
|
|
23
|
+
centerPx={seam.centerTime * pixelsPerSecond}
|
|
24
|
+
top={top}
|
|
25
|
+
widthPx={Math.min(Math.max(seam.duration * pixelsPerSecond, 24), 32)}
|
|
26
|
+
outgoingSrc={seam.outgoing.src}
|
|
27
|
+
incomingSrc={seam.incoming.src}
|
|
28
|
+
/>
|
|
29
|
+
));
|
|
30
|
+
}
|
|
@@ -36,7 +36,8 @@ afterEach(() => {
|
|
|
36
36
|
document.body.innerHTML = "";
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
async function render(
|
|
39
|
+
async function render(width = 0) {
|
|
40
|
+
Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
|
|
40
41
|
root = createRoot(host);
|
|
41
42
|
await act(async () => {
|
|
42
43
|
root!.render(
|
|
@@ -47,7 +48,6 @@ async function render(rich = false) {
|
|
|
47
48
|
projectId="p"
|
|
48
49
|
sessionEpoch={1}
|
|
49
50
|
priority="visible"
|
|
50
|
-
rich={rich}
|
|
51
51
|
/>,
|
|
52
52
|
);
|
|
53
53
|
await Promise.resolve();
|
|
@@ -55,7 +55,7 @@ async function render(rich = false) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
describe("VideoThumbnail", () => {
|
|
58
|
-
it("
|
|
58
|
+
it("does not acquire a thumbnail lease before the clip is measured", async () => {
|
|
59
59
|
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
60
60
|
value: { kind: "image", url: "blob:poster", aspect: 16 / 9 },
|
|
61
61
|
weight: 128,
|
|
@@ -63,29 +63,40 @@ describe("VideoThumbnail", () => {
|
|
|
63
63
|
|
|
64
64
|
await render();
|
|
65
65
|
|
|
66
|
-
expect(decodeVideoThumbnail).
|
|
67
|
-
expect.objectContaining({ frameCount: 1 }),
|
|
68
|
-
expect.any(AbortSignal),
|
|
69
|
-
);
|
|
70
|
-
expect(host.querySelector('img[src="blob:poster"]')).not.toBeNull();
|
|
71
|
-
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
66
|
+
expect(decodeVideoThumbnail).not.toHaveBeenCalled();
|
|
72
67
|
});
|
|
73
68
|
|
|
74
|
-
it("requests a
|
|
69
|
+
it("requests a geometry-sized filmstrip by default", async () => {
|
|
75
70
|
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
76
71
|
value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
|
|
77
72
|
weight: 256,
|
|
78
73
|
});
|
|
79
74
|
|
|
80
|
-
await render(
|
|
75
|
+
await render(500);
|
|
81
76
|
|
|
77
|
+
expect(decodeVideoThumbnail).toHaveBeenNthCalledWith(
|
|
78
|
+
1,
|
|
79
|
+
expect.objectContaining({ frameCount: 1 }),
|
|
80
|
+
expect.any(AbortSignal),
|
|
81
|
+
);
|
|
82
82
|
expect(decodeVideoThumbnail).toHaveBeenCalledWith(
|
|
83
|
-
expect.objectContaining({ frameCount:
|
|
83
|
+
expect.objectContaining({ frameCount: 8 }),
|
|
84
84
|
expect.any(AbortSignal),
|
|
85
85
|
);
|
|
86
86
|
expect(host.querySelectorAll("img").length).toBeGreaterThan(0);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
it("issues a single decode job for a narrow clip", async () => {
|
|
90
|
+
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
91
|
+
value: { kind: "image", url: "blob:poster", aspect: 16 / 9 },
|
|
92
|
+
weight: 128,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await render(100);
|
|
96
|
+
|
|
97
|
+
expect(decodeVideoThumbnail).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
89
100
|
it("clears the loading shimmer when the scheduled decode fails", async () => {
|
|
90
101
|
vi.mocked(decodeVideoThumbnail).mockRejectedValue(new Error("decode failed"));
|
|
91
102
|
|