@hyperframes/studio 0.7.94 → 0.7.96
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-D4mryRja.js → hyperframes-player-CogOkBTX.js} +1 -1
- package/dist/assets/{index-B0NWErIq.js → index-DfZlc9RM.js} +1 -1
- package/dist/assets/{index-BWh5m2-P.js → index-OMTw4iIl.js} +1 -1
- package/dist/assets/{index-CH_dyqrx.js → index-YO-rhwSW.js} +195 -195
- package/dist/assets/index-tBPidglp.css +1 -0
- package/dist/index.d.ts +27 -7
- package/dist/index.html +2 -2
- package/dist/index.js +2648 -1838
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.selectionSync.test.tsx +109 -0
- package/src/components/EditorShell.tsx +31 -5
- package/src/components/TimelineToolbar.test.tsx +20 -1
- package/src/components/TimelineToolbar.tsx +29 -1
- package/src/hooks/useRenderClipContent.test.ts +77 -2
- package/src/hooks/useRenderClipContent.ts +78 -14
- package/src/hooks/useThumbnailLease.test.tsx +150 -0
- package/src/hooks/useThumbnailLease.ts +44 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +79 -5
- package/src/hooks/useTimelineSelectionPreviewSync.ts +40 -13
- package/src/player/components/AudioWaveform.test.tsx +53 -0
- package/src/player/components/AudioWaveform.tsx +137 -140
- package/src/player/components/CompositionThumbnail.test.ts +33 -10
- package/src/player/components/CompositionThumbnail.tsx +78 -61
- package/src/player/components/ImageThumbnail.test.tsx +26 -14
- package/src/player/components/ImageThumbnail.tsx +58 -101
- package/src/player/components/Timeline.tsx +20 -19
- package/src/player/components/TimelineGestureOverlay.tsx +3 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -2
- package/src/player/components/TimelineLanes.tsx +12 -12
- package/src/player/components/TimelineTypes.ts +6 -0
- package/src/player/components/VideoThumbnail.test.tsx +46 -100
- package/src/player/components/VideoThumbnail.tsx +74 -156
- package/src/player/components/thumbnailUtils.test.ts +35 -0
- package/src/player/components/thumbnailUtils.ts +86 -8
- package/src/player/components/timelineClipChildren.test.ts +31 -0
- package/src/player/components/timelineClipChildren.tsx +21 -2
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +9 -2
- package/src/player/hooks/useTimelinePlayer.ts +14 -9
- package/src/player/lib/mediaProbe.test.ts +143 -0
- package/src/player/lib/mediaProbe.ts +117 -25
- package/src/player/lib/thumbnailPolicy.test.ts +14 -0
- package/src/player/lib/thumbnailPolicy.ts +24 -0
- package/src/player/lib/thumbnailScheduler.test.ts +388 -0
- package/src/player/lib/thumbnailScheduler.ts +483 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +127 -0
- package/src/player/lib/thumbnailVideoDecoder.ts +170 -0
- package/src/player/lib/timelineViewportBudgets.ts +2 -0
- package/src/player/store/playerStore.ts +3 -1
- package/src/player/store/thumbnailSlice.ts +18 -0
- package/src/telemetry/canary.test.ts +25 -0
- package/src/telemetry/canary.ts +14 -4
- package/src/utils/frameCapture.test.ts +1 -1
- package/src/utils/frameCapture.ts +1 -0
- package/src/utils/projectRouting.test.ts +1 -1
- package/src/utils/studioSelectionSnapshot.test.ts +1 -1
- package/src/utils/studioSelectionSnapshot.ts +1 -0
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/dist/assets/index-D78KEjgB.css +0 -1
|
@@ -1,74 +1,105 @@
|
|
|
1
|
-
import { memo,
|
|
1
|
+
import { memo, useCallback, useMemo, useRef } from "react";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
+
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
4
|
+
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
2
5
|
|
|
3
6
|
interface AudioWaveformProps {
|
|
4
7
|
audioUrl: string;
|
|
5
8
|
waveformUrl?: string;
|
|
6
9
|
label: string;
|
|
7
10
|
labelColor: string;
|
|
8
|
-
/**
|
|
9
|
-
* Fraction (0–1) of the source the clip starts at, after the media-start
|
|
10
|
-
* trim. Defaults to 0 (no front trim).
|
|
11
|
-
*/
|
|
12
11
|
trimStartFraction?: number;
|
|
13
|
-
/**
|
|
14
|
-
* Fraction (0–1) of the source the clip ends at. Defaults to 1 (no tail
|
|
15
|
-
* trim). Together these window the rendered peaks to the trimmed slice so the
|
|
16
|
-
* waveform tracks the clip edges instead of squeezing the whole file in.
|
|
17
|
-
*/
|
|
18
12
|
trimEndFraction?: number;
|
|
13
|
+
projectId: string;
|
|
14
|
+
sessionEpoch: number;
|
|
15
|
+
priority: ThumbnailPriority;
|
|
19
16
|
}
|
|
20
17
|
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const STEP = BAR_W + GAP;
|
|
18
|
+
const BAR_WIDTH = 2;
|
|
19
|
+
const BAR_STEP = 3;
|
|
24
20
|
|
|
25
|
-
/** Downsample PCM channel data into peak amplitudes (0–1). */
|
|
26
21
|
function extractPeaks(channelData: Float32Array, barCount: number): number[] {
|
|
27
22
|
const peaks: number[] = [];
|
|
28
23
|
const samplesPerBar = Math.floor(channelData.length / barCount);
|
|
29
24
|
if (samplesPerBar === 0) return Array(barCount).fill(0);
|
|
30
|
-
for (let
|
|
25
|
+
for (let index = 0; index < barCount; index++) {
|
|
31
26
|
let max = 0;
|
|
32
|
-
const start =
|
|
27
|
+
const start = index * samplesPerBar;
|
|
33
28
|
const end = Math.min(start + samplesPerBar, channelData.length);
|
|
34
|
-
for (let
|
|
35
|
-
|
|
36
|
-
const abs = Math.abs(channelData[j] ?? 0);
|
|
37
|
-
if (abs > max) max = abs;
|
|
29
|
+
for (let sample = start; sample < end; sample++) {
|
|
30
|
+
max = Math.max(max, Math.abs(channelData[sample] ?? 0));
|
|
38
31
|
}
|
|
39
32
|
peaks.push(max);
|
|
40
33
|
}
|
|
41
34
|
const maxPeak = Math.max(...peaks, 0.001);
|
|
42
|
-
return peaks.map((
|
|
35
|
+
return peaks.map((peak) => peak / maxPeak);
|
|
43
36
|
}
|
|
44
37
|
|
|
45
|
-
/** Deterministic fake waveform as fallback (matches demo app). */
|
|
46
38
|
function fakePeaks(url: string, count: number): number[] {
|
|
47
39
|
let seed = 0;
|
|
48
|
-
for (let
|
|
40
|
+
for (let index = 0; index < url.length; index++) {
|
|
41
|
+
seed = ((seed << 5) - seed + url.charCodeAt(index)) | 0;
|
|
42
|
+
}
|
|
49
43
|
seed = Math.abs(seed) || 42;
|
|
50
|
-
const
|
|
44
|
+
const random = () => {
|
|
51
45
|
seed = (seed * 16807) % 2147483647;
|
|
52
46
|
return (seed & 0x7fffffff) / 2147483647;
|
|
53
47
|
};
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
48
|
+
return Array.from({ length: count }, (_, index) => {
|
|
49
|
+
const time = index / count;
|
|
50
|
+
const envelope =
|
|
51
|
+
0.3 + 0.3 * Math.sin(time * Math.PI * 3.2) + 0.2 * Math.sin(time * Math.PI * 7.1);
|
|
52
|
+
return Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * random())));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function loadWaveform(
|
|
57
|
+
audioUrl: string,
|
|
58
|
+
waveformUrl: string | undefined,
|
|
59
|
+
signal: AbortSignal,
|
|
60
|
+
): Promise<number[]> {
|
|
61
|
+
try {
|
|
62
|
+
return waveformUrl
|
|
63
|
+
? await fetchWaveformPeaks(waveformUrl, signal)
|
|
64
|
+
: await decodeWaveformPeaks(audioUrl, signal);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (signal.aborted) throw error;
|
|
67
|
+
return fakePeaks(waveformUrl ?? audioUrl, 4000);
|
|
59
68
|
}
|
|
60
|
-
return peaks;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
71
|
+
async function fetchWaveformPeaks(url: string, signal: AbortSignal): Promise<number[]> {
|
|
72
|
+
const response = await fetch(url, { signal });
|
|
73
|
+
if (!response.ok) throw new Error(`Waveform request failed (${response.status})`);
|
|
74
|
+
const data: unknown = await response.json();
|
|
75
|
+
if (
|
|
76
|
+
typeof data !== "object" ||
|
|
77
|
+
data === null ||
|
|
78
|
+
!("peaks" in data) ||
|
|
79
|
+
!Array.isArray(data.peaks) ||
|
|
80
|
+
!data.peaks.every((peak) => typeof peak === "number")
|
|
81
|
+
) {
|
|
82
|
+
throw new Error("Invalid waveform response");
|
|
83
|
+
}
|
|
84
|
+
return data.peaks;
|
|
85
|
+
}
|
|
66
86
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
async function decodeWaveformPeaks(url: string, signal: AbortSignal): Promise<number[]> {
|
|
88
|
+
const response = await fetch(url, { signal });
|
|
89
|
+
if (!response.ok) throw new Error(`Audio request failed (${response.status})`);
|
|
90
|
+
const buffer = await response.arrayBuffer();
|
|
91
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
92
|
+
const context = new AudioContext();
|
|
93
|
+
try {
|
|
94
|
+
const decoded = await context.decodeAudioData(buffer);
|
|
95
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
96
|
+
return extractPeaks(decoded.getChannelData(0), 4000);
|
|
97
|
+
} finally {
|
|
98
|
+
await context.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Bounded waveform subscriber; cache, cancellation and dedupe live in one scheduler. */
|
|
72
103
|
export const AudioWaveform = memo(function AudioWaveform({
|
|
73
104
|
audioUrl,
|
|
74
105
|
waveformUrl,
|
|
@@ -76,130 +107,96 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
76
107
|
labelColor,
|
|
77
108
|
trimStartFraction,
|
|
78
109
|
trimEndFraction,
|
|
110
|
+
projectId,
|
|
111
|
+
sessionEpoch,
|
|
112
|
+
priority,
|
|
79
113
|
}: AudioWaveformProps) {
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const roRef = useRef<ResizeObserver | null>(null);
|
|
114
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
115
|
+
const observerRef = useRef<ResizeObserver | null>(null);
|
|
83
116
|
const cacheKey = waveformUrl ?? audioUrl;
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return ctx.decodeAudioData(buf).finally(() => ctx.close());
|
|
106
|
-
})
|
|
107
|
-
.then((decoded) => extractPeaks(decoded.getChannelData(0), 4000))
|
|
108
|
-
)
|
|
109
|
-
.catch(() => fakePeaks(cacheKey, 4000))
|
|
110
|
-
.then((p) => {
|
|
111
|
-
peaksCache.set(cacheKey, p);
|
|
112
|
-
return p;
|
|
113
|
-
})
|
|
114
|
-
.finally(() => decodeInFlight.delete(cacheKey));
|
|
115
|
-
|
|
116
|
-
decodeInFlight.set(cacheKey, promise);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
promise.then((p) => {
|
|
120
|
-
if (!cancelled) setPeaks(p);
|
|
121
|
-
});
|
|
122
|
-
return () => {
|
|
123
|
-
cancelled = true;
|
|
124
|
-
};
|
|
125
|
-
}, [audioUrl, waveformUrl, cacheKey, peaks]);
|
|
117
|
+
const request = useMemo(
|
|
118
|
+
() => ({
|
|
119
|
+
key: createThumbnailKey({ kind: "waveform", source: cacheKey }),
|
|
120
|
+
projectId,
|
|
121
|
+
sessionEpoch,
|
|
122
|
+
kind: "waveform" as const,
|
|
123
|
+
priority,
|
|
124
|
+
rich: false,
|
|
125
|
+
load: async (signal: AbortSignal) => {
|
|
126
|
+
const peaks = await loadWaveform(audioUrl, waveformUrl, signal);
|
|
127
|
+
return {
|
|
128
|
+
value: { kind: "waveform" as const, peaks },
|
|
129
|
+
weight: peaks.length * Float64Array.BYTES_PER_ELEMENT,
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
[audioUrl, cacheKey, priority, projectId, sessionEpoch, waveformUrl],
|
|
134
|
+
);
|
|
135
|
+
const snapshot = useThumbnailLease(cacheKey ? request : null);
|
|
136
|
+
const peaks =
|
|
137
|
+
snapshot.status === "ready" && snapshot.value.kind === "waveform" ? snapshot.value.peaks : null;
|
|
126
138
|
|
|
127
|
-
// Draw bars into the container using innerHTML (fast, zoom-resilient)
|
|
128
139
|
const draw = useCallback(() => {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const amp = peaks[peakIdx] ?? 0;
|
|
153
|
-
const pct = Math.max(3, Math.round(amp * 100));
|
|
154
|
-
const opacity = (0.45 + amp * 0.4).toFixed(2);
|
|
155
|
-
html += `<div style="position:absolute;bottom:0;left:${i * STEP}px;width:${BAR_W}px;height:${pct}%;background:rgba(75,163,210,${opacity})"></div>`;
|
|
140
|
+
const canvas = canvasRef.current;
|
|
141
|
+
if (!canvas || !peaks) return;
|
|
142
|
+
const width = Math.max(1, canvas.clientWidth);
|
|
143
|
+
const height = Math.max(1, canvas.clientHeight);
|
|
144
|
+
const scale = window.devicePixelRatio || 1;
|
|
145
|
+
canvas.width = Math.ceil(width * scale);
|
|
146
|
+
canvas.height = Math.ceil(height * scale);
|
|
147
|
+
const context = canvas.getContext("2d");
|
|
148
|
+
if (!context) return;
|
|
149
|
+
context.scale(scale, scale);
|
|
150
|
+
context.clearRect(0, 0, width, height);
|
|
151
|
+
const startFraction = Math.max(0, Math.min(1, trimStartFraction ?? 0));
|
|
152
|
+
const endFraction = Math.max(startFraction, Math.min(1, trimEndFraction ?? 1));
|
|
153
|
+
const start = Math.floor(startFraction * peaks.length);
|
|
154
|
+
const end = Math.max(start + 1, Math.ceil(endFraction * peaks.length));
|
|
155
|
+
const span = end - start;
|
|
156
|
+
const barCount = Math.floor(width / BAR_STEP);
|
|
157
|
+
for (let index = 0; index < barCount; index++) {
|
|
158
|
+
const peakIndex = start + Math.min(span - 1, Math.floor((index / barCount) * span));
|
|
159
|
+
const amplitude = peaks[peakIndex] ?? 0;
|
|
160
|
+
const barHeight = Math.max(2, amplitude * height);
|
|
161
|
+
context.fillStyle = `rgba(75,163,210,${(0.45 + amplitude * 0.4).toFixed(2)})`;
|
|
162
|
+
context.fillRect(index * BAR_STEP, height - barHeight, BAR_WIDTH, barHeight);
|
|
156
163
|
}
|
|
157
|
-
|
|
158
|
-
}, [peaks, trimStartFraction, trimEndFraction]);
|
|
164
|
+
}, [peaks, trimEndFraction, trimStartFraction]);
|
|
159
165
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (!el) return;
|
|
166
|
+
const setCanvasRef = useCallback(
|
|
167
|
+
(canvas: HTMLCanvasElement | null) => {
|
|
168
|
+
observerRef.current?.disconnect();
|
|
169
|
+
canvasRef.current = canvas;
|
|
170
|
+
if (!canvas) return;
|
|
166
171
|
draw();
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
observerRef.current = new ResizeObserver(draw);
|
|
173
|
+
observerRef.current.observe(canvas);
|
|
169
174
|
},
|
|
170
175
|
[draw],
|
|
171
176
|
);
|
|
172
177
|
|
|
173
|
-
|
|
174
|
-
useEffect(() => {
|
|
175
|
-
draw();
|
|
176
|
-
}, [draw]);
|
|
177
|
-
|
|
178
|
-
useEffect(
|
|
179
|
-
() => () => {
|
|
180
|
-
roRef.current?.disconnect();
|
|
181
|
-
},
|
|
182
|
-
[],
|
|
183
|
-
);
|
|
178
|
+
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
184
179
|
|
|
185
180
|
return (
|
|
186
|
-
<div
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
181
|
+
<div className="absolute inset-0 overflow-hidden">
|
|
182
|
+
<canvas
|
|
183
|
+
ref={setCanvasRef}
|
|
184
|
+
className="absolute inset-x-0 bottom-0 w-full"
|
|
185
|
+
style={{ top: 16 }}
|
|
186
|
+
/>
|
|
187
|
+
{snapshot.status === "loading" && (
|
|
190
188
|
<div
|
|
191
|
-
className="absolute
|
|
189
|
+
className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
|
|
192
190
|
style={{
|
|
193
|
-
top: 16,
|
|
194
191
|
background:
|
|
195
192
|
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
196
193
|
}}
|
|
197
194
|
/>
|
|
198
195
|
)}
|
|
199
196
|
{label && (
|
|
200
|
-
<div className="absolute
|
|
197
|
+
<div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
|
|
201
198
|
<span
|
|
202
|
-
className="text-[9px] font-semibold
|
|
199
|
+
className="block truncate text-[9px] font-semibold leading-tight"
|
|
203
200
|
style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
|
|
204
201
|
>
|
|
205
202
|
{label}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
6
7
|
import { buildCompositionThumbnailUrl, CompositionThumbnail } from "./CompositionThumbnail";
|
|
7
8
|
|
|
8
9
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
@@ -31,12 +32,18 @@ class MockImage {
|
|
|
31
32
|
|
|
32
33
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
33
34
|
const originalImage = globalThis.Image;
|
|
35
|
+
const originalFetch = globalThis.fetch;
|
|
36
|
+
const originalCreateObjectURL = URL.createObjectURL;
|
|
37
|
+
const originalRevokeObjectURL = URL.revokeObjectURL;
|
|
34
38
|
let host: HTMLDivElement;
|
|
35
39
|
let root: Root | null = null;
|
|
36
40
|
|
|
37
41
|
beforeEach(() => {
|
|
38
42
|
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
39
43
|
globalThis.Image = MockImage as unknown as typeof Image;
|
|
44
|
+
globalThis.fetch = vi.fn(async () => new Response(new Blob(["thumbnail"]), { status: 200 }));
|
|
45
|
+
URL.createObjectURL = vi.fn(() => "blob:composition-thumbnail");
|
|
46
|
+
URL.revokeObjectURL = vi.fn();
|
|
40
47
|
MockImage.instances = [];
|
|
41
48
|
host = document.createElement("div");
|
|
42
49
|
document.body.append(host);
|
|
@@ -45,8 +52,12 @@ beforeEach(() => {
|
|
|
45
52
|
afterEach(() => {
|
|
46
53
|
act(() => root?.unmount());
|
|
47
54
|
root = null;
|
|
55
|
+
thumbnailScheduler.invalidateProject("/api/projects/demo/preview");
|
|
48
56
|
globalThis.ResizeObserver = originalResizeObserver;
|
|
49
57
|
globalThis.Image = originalImage;
|
|
58
|
+
globalThis.fetch = originalFetch;
|
|
59
|
+
URL.createObjectURL = originalCreateObjectURL;
|
|
60
|
+
URL.revokeObjectURL = originalRevokeObjectURL;
|
|
50
61
|
document.body.replaceChildren();
|
|
51
62
|
});
|
|
52
63
|
|
|
@@ -68,9 +79,9 @@ describe("buildCompositionThumbnailUrl", () => {
|
|
|
68
79
|
});
|
|
69
80
|
|
|
70
81
|
describe("CompositionThumbnail", () => {
|
|
71
|
-
function renderThumbnail(): MockImage {
|
|
82
|
+
async function renderThumbnail(): Promise<MockImage> {
|
|
72
83
|
root = createRoot(host);
|
|
73
|
-
act(() => {
|
|
84
|
+
await act(async () => {
|
|
74
85
|
root!.render(
|
|
75
86
|
React.createElement(CompositionThumbnail, {
|
|
76
87
|
previewUrl: "/api/projects/demo/preview",
|
|
@@ -78,19 +89,27 @@ describe("CompositionThumbnail", () => {
|
|
|
78
89
|
labelColor: "#fff",
|
|
79
90
|
}),
|
|
80
91
|
);
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
81
93
|
});
|
|
82
94
|
const probe = MockImage.instances[0];
|
|
83
95
|
if (!probe) throw new Error("Expected an image probe");
|
|
84
96
|
return probe;
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
it("renders visible tiles after the off-DOM probe loads", () => {
|
|
88
|
-
const probe = renderThumbnail();
|
|
99
|
+
it("renders visible tiles after the scheduled off-DOM probe loads", async () => {
|
|
100
|
+
const probe = await renderThumbnail();
|
|
89
101
|
|
|
90
|
-
|
|
102
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
103
|
+
expect.stringContaining("/api/projects/demo/thumbnail/index.html"),
|
|
104
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
105
|
+
);
|
|
106
|
+
expect(probe.src).toBe("blob:composition-thumbnail");
|
|
107
|
+
|
|
108
|
+
await act(async () => {
|
|
91
109
|
probe.naturalWidth = 1920;
|
|
92
110
|
probe.naturalHeight = 1080;
|
|
93
111
|
probe.onload?.();
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
94
113
|
});
|
|
95
114
|
|
|
96
115
|
const tiles = [...host.querySelectorAll("img")];
|
|
@@ -98,16 +117,20 @@ describe("CompositionThumbnail", () => {
|
|
|
98
117
|
expect(tiles.every((tile) => !tile.classList.contains("hidden"))).toBe(true);
|
|
99
118
|
});
|
|
100
119
|
|
|
101
|
-
it("aborts its off-DOM image probe when unmounted", () => {
|
|
102
|
-
const probe = renderThumbnail();
|
|
120
|
+
it("aborts its scheduled off-DOM image probe when unmounted", async () => {
|
|
121
|
+
const probe = await renderThumbnail();
|
|
103
122
|
expect(host.querySelector("img")).toBeNull();
|
|
104
|
-
expect(probe.src).
|
|
123
|
+
expect(probe.src).toBe("blob:composition-thumbnail");
|
|
105
124
|
|
|
106
|
-
act(() =>
|
|
125
|
+
await act(async () => {
|
|
126
|
+
root?.unmount();
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
128
|
+
});
|
|
107
129
|
root = null;
|
|
108
130
|
|
|
109
131
|
expect(probe.onload).toBeNull();
|
|
110
132
|
expect(probe.onerror).toBeNull();
|
|
111
133
|
expect(probe.src).toBe("");
|
|
134
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:composition-thumbnail");
|
|
112
135
|
});
|
|
113
136
|
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { memo, useCallback,
|
|
1
|
+
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
+
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
4
|
+
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
5
|
+
import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
|
|
6
|
+
import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
|
|
3
7
|
|
|
4
8
|
interface CompositionThumbnailProps {
|
|
5
9
|
previewUrl: string;
|
|
@@ -11,6 +15,10 @@ interface CompositionThumbnailProps {
|
|
|
11
15
|
duration?: number;
|
|
12
16
|
width?: number;
|
|
13
17
|
height?: number;
|
|
18
|
+
projectId?: string;
|
|
19
|
+
sessionEpoch?: number;
|
|
20
|
+
priority?: ThumbnailPriority;
|
|
21
|
+
rich?: boolean;
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
const CLIP_HEIGHT = 66;
|
|
@@ -34,9 +42,8 @@ export function buildCompositionThumbnailUrl({
|
|
|
34
42
|
const thumbnailBase = previewUrl
|
|
35
43
|
.replace("/preview/comp/", "/thumbnail/")
|
|
36
44
|
.replace(/\/preview$/, "/thumbnail/index.html");
|
|
37
|
-
const midTime = seekTime + duration / 2;
|
|
38
45
|
const thumbnailUrl = new URL(thumbnailBase, origin);
|
|
39
|
-
thumbnailUrl.searchParams.set("t",
|
|
46
|
+
thumbnailUrl.searchParams.set("t", (seekTime + duration / 2).toFixed(2));
|
|
40
47
|
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
41
48
|
if (selector) {
|
|
42
49
|
thumbnailUrl.searchParams.set("selector", selector);
|
|
@@ -47,6 +54,29 @@ export function buildCompositionThumbnailUrl({
|
|
|
47
54
|
return thumbnailUrl.toString();
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
async function loadCompositionImage(url: string, signal: AbortSignal) {
|
|
58
|
+
const response = await fetch(url, { signal });
|
|
59
|
+
if (!response.ok) throw new Error(`Composition thumbnail failed (${response.status})`);
|
|
60
|
+
const blob = await response.blob();
|
|
61
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
62
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
63
|
+
try {
|
|
64
|
+
const aspect = await probeImageAspect(objectUrl, signal);
|
|
65
|
+
return {
|
|
66
|
+
value: { kind: "image" as const, url: objectUrl, aspect },
|
|
67
|
+
weight:
|
|
68
|
+
TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth *
|
|
69
|
+
TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight *
|
|
70
|
+
4,
|
|
71
|
+
dispose: () => URL.revokeObjectURL(objectUrl),
|
|
72
|
+
};
|
|
73
|
+
} catch (error) {
|
|
74
|
+
URL.revokeObjectURL(objectUrl);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Server-rendered composition poster, deduplicated and budgeted by project/session. */
|
|
50
80
|
export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
51
81
|
previewUrl,
|
|
52
82
|
label,
|
|
@@ -55,31 +85,12 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
55
85
|
selectorIndex,
|
|
56
86
|
seekTime = 2,
|
|
57
87
|
duration = 5,
|
|
88
|
+
projectId = previewUrl,
|
|
89
|
+
sessionEpoch = 0,
|
|
90
|
+
priority = "visible",
|
|
58
91
|
}: CompositionThumbnailProps) {
|
|
59
92
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
60
|
-
const
|
|
61
|
-
const [aspect, setAspect] = useState(16 / 9);
|
|
62
|
-
const roRef = useRef<ResizeObserver | null>(null);
|
|
63
|
-
|
|
64
|
-
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
|
65
|
-
roRef.current?.disconnect();
|
|
66
|
-
if (!el) return;
|
|
67
|
-
|
|
68
|
-
const measured = el.parentElement?.clientWidth || el.clientWidth;
|
|
69
|
-
// fallow-ignore-next-line code-duplication
|
|
70
|
-
setContainerWidth(measured);
|
|
71
|
-
|
|
72
|
-
const target = el.parentElement || el;
|
|
73
|
-
roRef.current = new ResizeObserver(([entry]) => {
|
|
74
|
-
setContainerWidth(entry.contentRect.width);
|
|
75
|
-
});
|
|
76
|
-
roRef.current.observe(target);
|
|
77
|
-
}, []);
|
|
78
|
-
|
|
79
|
-
useMountEffect(() => () => {
|
|
80
|
-
roRef.current?.disconnect();
|
|
81
|
-
});
|
|
82
|
-
|
|
93
|
+
const observerRef = useRef<ResizeObserver | null>(null);
|
|
83
94
|
const url = buildCompositionThumbnailUrl({
|
|
84
95
|
previewUrl,
|
|
85
96
|
seekTime,
|
|
@@ -88,52 +99,56 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
88
99
|
selectorIndex,
|
|
89
100
|
origin: window.location.origin,
|
|
90
101
|
});
|
|
102
|
+
const request = useMemo(
|
|
103
|
+
() => ({
|
|
104
|
+
key: createThumbnailKey({ kind: "composition", url }),
|
|
105
|
+
projectId,
|
|
106
|
+
sessionEpoch,
|
|
107
|
+
kind: "composition" as const,
|
|
108
|
+
priority,
|
|
109
|
+
rich: true,
|
|
110
|
+
load: (signal: AbortSignal) => loadCompositionImage(url, signal),
|
|
111
|
+
}),
|
|
112
|
+
[priority, projectId, sessionEpoch, url],
|
|
113
|
+
);
|
|
114
|
+
const snapshot = useThumbnailLease(request);
|
|
115
|
+
const value =
|
|
116
|
+
snapshot.status === "ready" && snapshot.value.kind === "image" ? snapshot.value : null;
|
|
117
|
+
const { frameW, frameCount } = computeThumbnailStrip(
|
|
118
|
+
containerWidth,
|
|
119
|
+
value?.aspect ?? 16 / 9,
|
|
120
|
+
CLIP_HEIGHT,
|
|
121
|
+
48,
|
|
122
|
+
);
|
|
91
123
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (probe.naturalWidth > 0 && probe.naturalHeight > 0) {
|
|
103
|
-
setAspect(probe.naturalWidth / probe.naturalHeight);
|
|
104
|
-
}
|
|
105
|
-
setLoaded(true);
|
|
106
|
-
};
|
|
107
|
-
probe.onerror = () => {
|
|
108
|
-
if (!cancelled) setLoaded(false);
|
|
109
|
-
};
|
|
110
|
-
probe.src = url;
|
|
111
|
-
return () => {
|
|
112
|
-
cancelled = true;
|
|
113
|
-
probe.onload = null;
|
|
114
|
-
probe.onerror = null;
|
|
115
|
-
probe.src = "";
|
|
116
|
-
};
|
|
117
|
-
}, [url]);
|
|
124
|
+
const setContainerRef = useCallback((element: HTMLDivElement | null) => {
|
|
125
|
+
observerRef.current?.disconnect();
|
|
126
|
+
if (!element) return;
|
|
127
|
+
const target = element.parentElement ?? element;
|
|
128
|
+
setContainerWidth(target.clientWidth);
|
|
129
|
+
observerRef.current = new ResizeObserver(([entry]) =>
|
|
130
|
+
setContainerWidth(entry.contentRect.width),
|
|
131
|
+
);
|
|
132
|
+
observerRef.current.observe(target);
|
|
133
|
+
}, []);
|
|
118
134
|
|
|
119
|
-
|
|
120
|
-
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
135
|
+
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
121
136
|
|
|
122
137
|
return (
|
|
123
138
|
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
124
|
-
{
|
|
139
|
+
{value && (
|
|
125
140
|
<div
|
|
126
141
|
className="absolute inset-0 flex"
|
|
127
142
|
style={{ animation: "hf-thumb-fade 200ms ease-out", mixBlendMode: "lighten" }}
|
|
128
143
|
>
|
|
129
|
-
{Array.from({ length: frameCount }
|
|
144
|
+
{Array.from({ length: frameCount }, (_, index) => (
|
|
130
145
|
<div
|
|
131
|
-
key={
|
|
146
|
+
key={index}
|
|
132
147
|
className="relative h-full flex-shrink-0 overflow-hidden"
|
|
133
148
|
style={{ width: frameW }}
|
|
134
149
|
>
|
|
135
150
|
<img
|
|
136
|
-
src={url}
|
|
151
|
+
src={value.url}
|
|
137
152
|
alt=""
|
|
138
153
|
draggable={false}
|
|
139
154
|
className="absolute inset-0 h-full w-full object-cover"
|
|
@@ -143,14 +158,16 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
143
158
|
))}
|
|
144
159
|
</div>
|
|
145
160
|
)}
|
|
146
|
-
|
|
161
|
+
{snapshot.status === "loading" && (
|
|
162
|
+
<div className="absolute inset-0 animate-pulse bg-white/[0.035]" />
|
|
163
|
+
)}
|
|
147
164
|
{label && (
|
|
148
|
-
<div className="absolute left-3
|
|
165
|
+
<div className="absolute inset-y-0 left-3 z-10 flex items-center">
|
|
149
166
|
<span
|
|
150
167
|
className="block max-w-full truncate text-[10px] font-semibold leading-none"
|
|
151
168
|
style={{
|
|
152
169
|
color: labelColor,
|
|
153
|
-
textShadow:
|
|
170
|
+
textShadow: value ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
|
|
154
171
|
}}
|
|
155
172
|
>
|
|
156
173
|
{label}
|