@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
|
@@ -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
|
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createThumbnailKey,
|
|
6
|
+
type ThumbnailPriority,
|
|
7
|
+
type ThumbnailSnapshot,
|
|
8
|
+
} from "../lib/thumbnailScheduler";
|
|
5
9
|
import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
|
|
6
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
computeThumbnailStrip,
|
|
12
|
+
quantizeThumbnailFrameCount,
|
|
13
|
+
THUMBNAIL_CLIP_HEIGHT,
|
|
14
|
+
} from "./thumbnailUtils";
|
|
7
15
|
|
|
8
16
|
interface VideoThumbnailProps {
|
|
9
17
|
videoSrc: string;
|
|
@@ -15,7 +23,58 @@ interface VideoThumbnailProps {
|
|
|
15
23
|
projectId?: string;
|
|
16
24
|
sessionEpoch?: number;
|
|
17
25
|
priority?: ThumbnailPriority;
|
|
18
|
-
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createVideoThumbnailRequest(
|
|
29
|
+
props: Pick<VideoThumbnailProps, "videoSrc" | "sourceStart" | "sourceRangeDuration"> &
|
|
30
|
+
Required<Pick<VideoThumbnailProps, "duration" | "projectId" | "sessionEpoch" | "priority">>,
|
|
31
|
+
frameCount: number,
|
|
32
|
+
rich: boolean,
|
|
33
|
+
) {
|
|
34
|
+
const {
|
|
35
|
+
videoSrc,
|
|
36
|
+
sourceStart,
|
|
37
|
+
sourceRangeDuration,
|
|
38
|
+
duration,
|
|
39
|
+
projectId,
|
|
40
|
+
sessionEpoch,
|
|
41
|
+
priority,
|
|
42
|
+
} = props;
|
|
43
|
+
return {
|
|
44
|
+
key: createThumbnailKey({
|
|
45
|
+
kind: "video",
|
|
46
|
+
source: videoSrc,
|
|
47
|
+
start: sourceStart,
|
|
48
|
+
duration: sourceRangeDuration ?? duration,
|
|
49
|
+
frames: frameCount,
|
|
50
|
+
}),
|
|
51
|
+
projectId,
|
|
52
|
+
sessionEpoch,
|
|
53
|
+
kind: "video" as const,
|
|
54
|
+
priority,
|
|
55
|
+
rich,
|
|
56
|
+
load: (signal: AbortSignal) =>
|
|
57
|
+
decodeVideoThumbnail(
|
|
58
|
+
{
|
|
59
|
+
source: videoSrc,
|
|
60
|
+
sourceStart,
|
|
61
|
+
sourceRangeDuration: sourceRangeDuration ?? duration,
|
|
62
|
+
frameCount,
|
|
63
|
+
fit: "cover",
|
|
64
|
+
},
|
|
65
|
+
signal,
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function selectThumbnailSnapshot(
|
|
71
|
+
poster: ThumbnailSnapshot,
|
|
72
|
+
rich: ThumbnailSnapshot,
|
|
73
|
+
): ThumbnailSnapshot {
|
|
74
|
+
if (rich.status === "ready") return rich;
|
|
75
|
+
if (poster.status === "ready") return poster;
|
|
76
|
+
if (rich.status === "loading" || poster.status === "loading") return { status: "loading" };
|
|
77
|
+
return poster;
|
|
19
78
|
}
|
|
20
79
|
|
|
21
80
|
/** Sparse, bounded video frames supplied by the shared thumbnail scheduler. */
|
|
@@ -29,39 +88,36 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
29
88
|
projectId = videoSrc,
|
|
30
89
|
sessionEpoch = 0,
|
|
31
90
|
priority = "visible",
|
|
32
|
-
rich = false,
|
|
33
91
|
}: VideoThumbnailProps) {
|
|
34
92
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
35
93
|
const observerRef = useRef<ResizeObserver | null>(null);
|
|
36
|
-
const
|
|
94
|
+
const requestFrameCount = quantizeThumbnailFrameCount(
|
|
95
|
+
computeThumbnailStrip(containerWidth, 16 / 9).frameCount,
|
|
96
|
+
);
|
|
97
|
+
const requestProps = useMemo(
|
|
37
98
|
() => ({
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
duration: sourceRangeDuration ?? duration,
|
|
43
|
-
frames: rich ? 6 : 1,
|
|
44
|
-
}),
|
|
99
|
+
videoSrc,
|
|
100
|
+
sourceStart,
|
|
101
|
+
sourceRangeDuration,
|
|
102
|
+
duration,
|
|
45
103
|
projectId,
|
|
46
104
|
sessionEpoch,
|
|
47
|
-
kind: "video" as const,
|
|
48
105
|
priority,
|
|
49
|
-
rich,
|
|
50
|
-
load: (signal: AbortSignal) =>
|
|
51
|
-
decodeVideoThumbnail(
|
|
52
|
-
{
|
|
53
|
-
source: videoSrc,
|
|
54
|
-
sourceStart,
|
|
55
|
-
sourceRangeDuration: sourceRangeDuration ?? duration,
|
|
56
|
-
frameCount: rich ? 6 : 1,
|
|
57
|
-
fit: "cover",
|
|
58
|
-
},
|
|
59
|
-
signal,
|
|
60
|
-
),
|
|
61
106
|
}),
|
|
62
|
-
[duration, priority, projectId,
|
|
107
|
+
[duration, priority, projectId, sessionEpoch, sourceRangeDuration, sourceStart, videoSrc],
|
|
108
|
+
);
|
|
109
|
+
const posterRequest = useMemo(
|
|
110
|
+
() => createVideoThumbnailRequest(requestProps, 1, false),
|
|
111
|
+
[requestProps],
|
|
112
|
+
);
|
|
113
|
+
const richRequest = useMemo(
|
|
114
|
+
() => createVideoThumbnailRequest(requestProps, requestFrameCount, true),
|
|
115
|
+
[requestFrameCount, requestProps],
|
|
63
116
|
);
|
|
64
|
-
const
|
|
117
|
+
const measured = containerWidth > 0;
|
|
118
|
+
const posterSnapshot = useThumbnailLease(measured ? posterRequest : null);
|
|
119
|
+
const richSnapshot = useThumbnailLease(measured && requestFrameCount > 1 ? richRequest : null);
|
|
120
|
+
const snapshot = selectThumbnailSnapshot(posterSnapshot, richSnapshot);
|
|
65
121
|
const value = snapshot.status === "ready" ? snapshot.value : null;
|
|
66
122
|
const urls =
|
|
67
123
|
value?.kind === "filmstrip" ? value.urls : value?.kind === "image" ? [value.url] : [];
|
|
@@ -112,8 +168,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
112
168
|
<div
|
|
113
169
|
className="absolute inset-0 animate-pulse motion-reduce:animate-none"
|
|
114
170
|
style={{
|
|
115
|
-
background:
|
|
116
|
-
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
171
|
+
background: "var(--timeline-thumbnail-shimmer)",
|
|
117
172
|
}}
|
|
118
173
|
/>
|
|
119
174
|
)}
|
|
@@ -128,13 +183,12 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
128
183
|
<div
|
|
129
184
|
className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
|
|
130
185
|
style={{
|
|
131
|
-
background:
|
|
132
|
-
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
|
186
|
+
background: "var(--timeline-thumbnail-label-gradient)",
|
|
133
187
|
}}
|
|
134
188
|
>
|
|
135
189
|
<span
|
|
136
190
|
className="block truncate text-[9px] font-semibold leading-tight"
|
|
137
|
-
style={{ color: labelColor, textShadow: "
|
|
191
|
+
style={{ color: labelColor, textShadow: "var(--timeline-thumbnail-label-shadow)" }}
|
|
138
192
|
>
|
|
139
193
|
{label}
|
|
140
194
|
</span>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { audioPillFlags, isLinkedVideoAudio } from "./audioClipLink";
|
|
3
|
+
|
|
4
|
+
const audio = { id: "bed", tag: "audio", src: "assets/City Ride.mp4?v=2" };
|
|
5
|
+
|
|
6
|
+
describe("isLinkedVideoAudio", () => {
|
|
7
|
+
it("links an audio pill to a video of the same file", () => {
|
|
8
|
+
expect(
|
|
9
|
+
isLinkedVideoAudio(audio, [
|
|
10
|
+
audio,
|
|
11
|
+
{ id: "picture", tag: "video", src: "/preview/assets/city ride.mp4" },
|
|
12
|
+
]),
|
|
13
|
+
).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("greys a hidden clip and a muted group without dropping the link", () => {
|
|
17
|
+
expect(audioPillFlags({ ...audio, hidden: true }, [])).toEqual({
|
|
18
|
+
muted: true,
|
|
19
|
+
linked: false,
|
|
20
|
+
});
|
|
21
|
+
expect(audioPillFlags({ ...audio, audioGroupHidden: true }, [])).toEqual({
|
|
22
|
+
muted: true,
|
|
23
|
+
linked: false,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("leaves a standalone audio file unlinked", () => {
|
|
28
|
+
expect(
|
|
29
|
+
isLinkedVideoAudio({ id: "vo", tag: "audio", src: "assets/voice.wav" }, [
|
|
30
|
+
{ id: "picture", tag: "video", src: "assets/city.mp4" },
|
|
31
|
+
]),
|
|
32
|
+
).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
2
|
+
|
|
3
|
+
function mediaFileKey(src: string | undefined): string | null {
|
|
4
|
+
if (!src) return null;
|
|
5
|
+
const path = src.split(/[?#]/, 1)[0] ?? "";
|
|
6
|
+
const segment = path.split(/[/\\]/).pop()?.trim().toLowerCase() ?? "";
|
|
7
|
+
return segment.length > 0 ? segment : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function audioPillFlags(
|
|
11
|
+
audio: Pick<TimelineElement, "id" | "tag" | "src" | "hidden" | "audioGroupHidden">,
|
|
12
|
+
elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
|
|
13
|
+
): { muted: boolean; linked: boolean } {
|
|
14
|
+
return {
|
|
15
|
+
muted: audio.hidden === true || audio.audioGroupHidden === true,
|
|
16
|
+
linked: isLinkedVideoAudio(audio, elements),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** An audio pill whose file is also a video clip on this timeline. */
|
|
21
|
+
export function isLinkedVideoAudio(
|
|
22
|
+
audio: Pick<TimelineElement, "id" | "tag" | "src">,
|
|
23
|
+
elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
|
|
24
|
+
): boolean {
|
|
25
|
+
if (audio.tag.trim().toLowerCase() !== "audio") return false;
|
|
26
|
+
const key = mediaFileKey(audio.src);
|
|
27
|
+
if (!key) return false;
|
|
28
|
+
return elements.some((element) => {
|
|
29
|
+
if (element.id === audio.id) return false;
|
|
30
|
+
if (element.tag.trim().toLowerCase() !== "video") return false;
|
|
31
|
+
return mediaFileKey(element.src) === key;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
|
|
3
|
+
|
|
4
|
+
describe("loudnessToOpacity", () => {
|
|
5
|
+
it("maps silence to the floor and the file peak to fully opaque", () => {
|
|
6
|
+
expect(loudnessToOpacity(0)).toBe(0.64);
|
|
7
|
+
expect(loudnessToOpacity(1)).toBe(1);
|
|
8
|
+
expect(loudnessToOpacity(0.5)).toBeCloseTo(0.82, 5);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("clamps out-of-range and non-finite peaks to the same floor or ceiling", () => {
|
|
12
|
+
expect(loudnessToOpacity(-0.2)).toBe(0.64);
|
|
13
|
+
expect(loudnessToOpacity(4)).toBe(1);
|
|
14
|
+
expect(loudnessToOpacity(Number.NaN)).toBe(0.64);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("decimatePeaks", () => {
|
|
19
|
+
it("keeps a transient that a single sample in the bucket would miss", () => {
|
|
20
|
+
const peaks = [0, 0, 0, 1, 0, 0, 0, 0];
|
|
21
|
+
expect(decimatePeaks(peaks, 0, 1, 2)).toEqual([1, 0]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("windows to the trimmed source slice", () => {
|
|
25
|
+
expect(decimatePeaks([0.1, 0.2, 0.9, 0.3], 0.5, 1, 1)).toEqual([0.9]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns no bars when there is nothing to draw", () => {
|
|
29
|
+
expect(decimatePeaks([], 0, 1, 4)).toEqual([]);
|
|
30
|
+
expect(decimatePeaks([0.5], 0, 1, 0)).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Quietest bar that still clears the timeline contrast gate on the 70% pill. */
|
|
2
|
+
const LOUDNESS_OPACITY_FLOOR = 0.64;
|
|
3
|
+
|
|
4
|
+
const LOUDNESS_OPACITY_SPAN = 1 - LOUDNESS_OPACITY_FLOOR;
|
|
5
|
+
|
|
6
|
+
/** Peak is 0–1 against the file's own maximum. Hotter peaks paint more opaque. */
|
|
7
|
+
export function loudnessToOpacity(peak: number): number {
|
|
8
|
+
if (!Number.isFinite(peak)) return LOUDNESS_OPACITY_FLOOR;
|
|
9
|
+
const clamped = Math.min(1, Math.max(0, peak));
|
|
10
|
+
return LOUDNESS_OPACITY_FLOOR + LOUDNESS_OPACITY_SPAN * clamped;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Max-in-bucket over the trimmed peak window, one value per bar.
|
|
15
|
+
* A point sample drops a transient that sits between bar columns.
|
|
16
|
+
*/
|
|
17
|
+
export function decimatePeaks(
|
|
18
|
+
peaks: readonly number[],
|
|
19
|
+
startFraction: number,
|
|
20
|
+
endFraction: number,
|
|
21
|
+
barCount: number,
|
|
22
|
+
): number[] {
|
|
23
|
+
if (barCount <= 0 || peaks.length === 0) return [];
|
|
24
|
+
const startFractionClamped = Math.min(1, Math.max(0, startFraction));
|
|
25
|
+
const endFractionClamped = Math.min(1, Math.max(startFractionClamped, endFraction));
|
|
26
|
+
const start = Math.floor(startFractionClamped * peaks.length);
|
|
27
|
+
const endIndex = Math.ceil(endFractionClamped * peaks.length);
|
|
28
|
+
const end = Math.max(start + 1, Math.min(peaks.length, endIndex));
|
|
29
|
+
const span = end - start;
|
|
30
|
+
const bars: number[] = [];
|
|
31
|
+
for (let index = 0; index < barCount; index++) {
|
|
32
|
+
let from = start + Math.floor((index * span) / barCount);
|
|
33
|
+
let to = start + Math.floor(((index + 1) * span) / barCount);
|
|
34
|
+
if (to <= from) {
|
|
35
|
+
from = Math.min(from, end - 1);
|
|
36
|
+
to = from + 1;
|
|
37
|
+
}
|
|
38
|
+
let max = 0;
|
|
39
|
+
for (let cursor = from; cursor < to; cursor++) {
|
|
40
|
+
const value = peaks[cursor] ?? 0;
|
|
41
|
+
if (value > max) max = value;
|
|
42
|
+
}
|
|
43
|
+
bars.push(max);
|
|
44
|
+
}
|
|
45
|
+
return bars;
|
|
46
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
computeThumbnailStrip,
|
|
4
4
|
encodePreviewPath,
|
|
5
5
|
resolveMediaPreviewUrl,
|
|
6
|
+
quantizeThumbnailFrameCount,
|
|
6
7
|
THUMBNAIL_CLIP_HEIGHT,
|
|
7
8
|
} from "./thumbnailUtils";
|
|
8
9
|
|
|
@@ -19,6 +20,10 @@ describe("computeThumbnailStrip", () => {
|
|
|
19
20
|
expect(frameCount * frameW).toBeGreaterThanOrEqual(500);
|
|
20
21
|
});
|
|
21
22
|
|
|
23
|
+
it("paints tiles across the full clip past the shared visible-frame budget", () => {
|
|
24
|
+
expect(computeThumbnailStrip(14_400, 16 / 9).frameCount).toBeGreaterThan(33);
|
|
25
|
+
});
|
|
26
|
+
|
|
22
27
|
it("returns one tile when the container width is unknown", () => {
|
|
23
28
|
expect(computeThumbnailStrip(0, 16 / 9).frameCount).toBe(1);
|
|
24
29
|
expect(computeThumbnailStrip(-10, 16 / 9).frameCount).toBe(1);
|
|
@@ -50,6 +55,18 @@ describe("computeThumbnailStrip", () => {
|
|
|
50
55
|
});
|
|
51
56
|
});
|
|
52
57
|
|
|
58
|
+
describe("quantizeThumbnailFrameCount", () => {
|
|
59
|
+
it("uses doubling buckets and never exceeds the 4K geometry ceiling", () => {
|
|
60
|
+
expect(quantizeThumbnailFrameCount(5)).toBe(8);
|
|
61
|
+
expect(quantizeThumbnailFrameCount(32)).toBe(32);
|
|
62
|
+
expect(quantizeThumbnailFrameCount(34)).toBe(33);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("caps decode requests at the shared visible-frame budget", () => {
|
|
66
|
+
expect(quantizeThumbnailFrameCount(124)).toBe(33);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
53
70
|
describe("resolveMediaPreviewUrl", () => {
|
|
54
71
|
it("reroutes same-origin root media resolved by the preview iframe", () => {
|
|
55
72
|
expect(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
|
+
import { MAX_VISIBLE_THUMBNAIL_FRAMES } from "../lib/timelineViewportBudgets";
|
|
2
3
|
/** Rendered height of a timeline-clip thumbnail strip, in CSS px. */
|
|
3
4
|
export const THUMBNAIL_CLIP_HEIGHT = 66;
|
|
4
5
|
|
|
@@ -9,6 +10,12 @@ export interface ThumbnailStripLayout {
|
|
|
9
10
|
frameCount: number;
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
/** Quantize request identities so a pixel-by-pixel resize does not thrash the cache. */
|
|
14
|
+
export function quantizeThumbnailFrameCount(frameCount: number): number {
|
|
15
|
+
const safeCount = Math.max(1, Number.isFinite(frameCount) ? Math.ceil(frameCount) : 1);
|
|
16
|
+
return Math.min(MAX_VISIBLE_THUMBNAIL_FRAMES, 2 ** Math.ceil(Math.log2(safeCount)));
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
/**
|
|
13
20
|
* Measure an image without mounting it in React's DOM. The scheduler owns the
|
|
14
21
|
* abort signal, so an unmounted clip cannot leave Blink retaining a pending
|
|
@@ -2,17 +2,17 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
|
|
4
4
|
const studioCss = readFileSync(new URL("../../styles/studio.css", import.meta.url), "utf8");
|
|
5
|
+
const themeCss = readFileSync(new URL("../../styles/theme.css", import.meta.url), "utf8");
|
|
6
|
+
const timelineOverlaySources = [
|
|
7
|
+
"TimelineShortcutHint.tsx",
|
|
8
|
+
"LayerDisclosureRow.tsx",
|
|
9
|
+
"ImageThumbnail.tsx",
|
|
10
|
+
"AudioWaveform.tsx",
|
|
11
|
+
"VideoThumbnail.tsx",
|
|
12
|
+
].map((fileName) => readFileSync(new URL(`./${fileName}`, import.meta.url), "utf8"));
|
|
5
13
|
const timelineClipSource = readFileSync(new URL("./TimelineClip.tsx", import.meta.url), "utf8");
|
|
6
14
|
const playheadSource = readFileSync(new URL("./PlayheadIndicator.tsx", import.meta.url), "utf8");
|
|
7
15
|
|
|
8
|
-
const allowedTimelineTransitionProperties = [
|
|
9
|
-
"background-color",
|
|
10
|
-
"border-color",
|
|
11
|
-
"box-shadow",
|
|
12
|
-
"color",
|
|
13
|
-
"opacity",
|
|
14
|
-
];
|
|
15
|
-
|
|
16
16
|
function expectRule(css: string, selector: string): string {
|
|
17
17
|
const selectorStart = css.indexOf(`${selector} {`);
|
|
18
18
|
expect(selectorStart).toBeGreaterThanOrEqual(0);
|
|
@@ -31,28 +31,26 @@ function expectDeclaration(ruleBody: string, property: string): string {
|
|
|
31
31
|
return declarationMatch?.[1].trim() ?? "";
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
for (const char of transitionDeclaration) {
|
|
40
|
-
if (char === "(") depth += 1;
|
|
41
|
-
if (char === ")") depth -= 1;
|
|
42
|
-
if (char === "," && depth === 0) {
|
|
43
|
-
items.push(item.trim());
|
|
44
|
-
item = "";
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
item += char;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (item.trim().length > 0) items.push(item.trim());
|
|
51
|
-
|
|
52
|
-
return items.map((transition) => transition.split(/\s+/)[0]);
|
|
34
|
+
function themeTokenValue(token: string): string {
|
|
35
|
+
const match = new RegExp(`${token}:\\s*([^;]+);`).exec(themeCss);
|
|
36
|
+
expect(match?.[1]).toBeDefined();
|
|
37
|
+
return match?.[1].trim() ?? "";
|
|
53
38
|
}
|
|
54
39
|
|
|
55
40
|
describe("timeline motion styles", () => {
|
|
41
|
+
it.each([
|
|
42
|
+
["--timeline-track-label", "rgba(255, 255, 255, 0.5)"],
|
|
43
|
+
["--timeline-tick-text", "rgba(255, 255, 255, 0.34)"],
|
|
44
|
+
["--timeline-border-strong", "rgba(255, 255, 255, 0.2)"],
|
|
45
|
+
["--timeline-group-member-tint", "rgba(255, 255, 255, 0.035)"],
|
|
46
|
+
["--timeline-overlay-text", "rgba(255, 255, 255, 0.8)"],
|
|
47
|
+
["--timeline-clip-shadow-dragging", "rgba(0, 0, 0, 0.4)"],
|
|
48
|
+
["--timeline-playhead-glow", "rgba(60, 230, 172, 0.14)"],
|
|
49
|
+
["--timeline-playhead-shadow", "rgba(0, 0, 0, 0.55)"],
|
|
50
|
+
])("keeps the migrated default for %s", (token, expected) => {
|
|
51
|
+
expect(themeTokenValue(token)).toBe(expected);
|
|
52
|
+
});
|
|
53
|
+
|
|
56
54
|
it("keeps clip motion reduced-motion gated and layout safe", () => {
|
|
57
55
|
const mediaStart = studioCss.indexOf("@media (prefers-reduced-motion: no-preference)");
|
|
58
56
|
expect(mediaStart).toBeGreaterThanOrEqual(0);
|
|
@@ -63,19 +61,73 @@ describe("timeline motion styles", () => {
|
|
|
63
61
|
|
|
64
62
|
const motionMediaCss = studioCss.slice(mediaStart);
|
|
65
63
|
const timelineClipMotionRule = expectRule(motionMediaCss, ".timeline-clip");
|
|
66
|
-
const clipTransition = expectDeclaration(timelineClipMotionRule, "transition");
|
|
67
64
|
|
|
68
|
-
expect(
|
|
69
|
-
|
|
65
|
+
expect(expectDeclaration(timelineClipMotionRule, "transition")).toBe(
|
|
66
|
+
"background-color 120ms ease-out",
|
|
67
|
+
);
|
|
70
68
|
});
|
|
71
69
|
|
|
72
70
|
it("layers the active mint bloom through opacity instead of a gradient background swap", () => {
|
|
73
71
|
const baseTimelineClipRule = expectRule(studioCss, ".timeline-clip");
|
|
72
|
+
const timelineClipLabelRule = expectRule(studioCss, ".timeline-clip__label");
|
|
73
|
+
const timelineClipTimecodeRule = expectRule(studioCss, ".timeline-clip__timecode");
|
|
74
|
+
const audioClipRule = expectRule(studioCss, ".timeline-clip.is-audio");
|
|
75
|
+
const audioClipHoverRule = expectRule(studioCss, ".timeline-clip.is-audio.is-hovered");
|
|
76
|
+
const audioClipDraggingRule = expectRule(studioCss, ".timeline-clip.is-audio.is-dragging");
|
|
74
77
|
const activeTimelineClipRule = expectRule(studioCss, ".timeline-clip[data-active]");
|
|
78
|
+
const selectedTimelineClipRule = expectRule(studioCss, ".timeline-clip.is-selected");
|
|
79
|
+
const activeSelectedTimelineClipRule = expectRule(
|
|
80
|
+
studioCss,
|
|
81
|
+
".timeline-clip[data-active].is-selected",
|
|
82
|
+
);
|
|
75
83
|
const bloomOverlayRule = expectRule(studioCss, ".timeline-clip::before");
|
|
76
84
|
const activeBloomOverlayRule = expectRule(studioCss, ".timeline-clip[data-active]::before");
|
|
77
85
|
|
|
78
86
|
expect(baseTimelineClipRule).toContain("background-color: var(--clip-bg)");
|
|
87
|
+
expect(baseTimelineClipRule).toContain("border: 1px solid var(--clip-border)");
|
|
88
|
+
expect(baseTimelineClipRule).toContain("box-shadow:");
|
|
89
|
+
expect(baseTimelineClipRule).toContain("inset 0 1px 0 var(--timeline-clip-highlight)");
|
|
90
|
+
expect(baseTimelineClipRule).toContain("var(--timeline-clip-shadow)");
|
|
91
|
+
expect(timelineClipLabelRule).toContain("background: var(--timeline-clip-chip-bg)");
|
|
92
|
+
expect(timelineClipLabelRule).toContain("color: var(--timeline-clip-chip-text)");
|
|
93
|
+
expect(timelineClipLabelRule).toContain("text-overflow: ellipsis");
|
|
94
|
+
expect(timelineClipLabelRule).toContain("max-width: calc(100% - 22px)");
|
|
95
|
+
expect(timelineClipTimecodeRule).toContain("color: var(--timeline-tick-text)");
|
|
96
|
+
expect(themeCss).toContain("--timeline-clip-bg: rgba(255, 255, 255, 0.12)");
|
|
97
|
+
expect(themeCss).toContain("--timeline-clip-border: rgba(255, 255, 255, 0.22)");
|
|
98
|
+
expect(themeCss).toContain("--timeline-track-label: rgba(255, 255, 255, 0.5)");
|
|
99
|
+
expect(themeCss).toContain("--timeline-tick-text: rgba(255, 255, 255, 0.34)");
|
|
100
|
+
expect(themeCss).toContain("--timeline-clip-label-active: rgba(232, 255, 247, 0.95)");
|
|
101
|
+
expect(themeCss).toContain("--timeline-clip-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.85)");
|
|
102
|
+
expect(themeCss).toContain("--timeline-clip-selection: rgba(255, 255, 255, 0.85)");
|
|
103
|
+
expect(themeCss).toContain("--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55)");
|
|
104
|
+
expect(themeCss).toContain("--timeline-clip-radius: 8px");
|
|
105
|
+
expect(themeCss).toContain("--timeline-clip-audio-radius: 21px");
|
|
106
|
+
expect(themeCss).toContain("--timeline-clip-highlight: rgb(255 255 255 / 0.08)");
|
|
107
|
+
expect(themeCss).toContain("--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.3)");
|
|
108
|
+
expect(themeCss).toContain("--timeline-clip-chip-bg: rgba(15, 22, 18, 0.73)");
|
|
109
|
+
expect(themeCss).toContain("--timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7)");
|
|
110
|
+
expect(themeCss).toContain("--timeline-clip-chip-text: #ffffff");
|
|
111
|
+
expect(themeCss).toContain("--timeline-row-bg: #edf0ed");
|
|
112
|
+
expect(themeCss).toContain("--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.12)");
|
|
113
|
+
expect(themeCss).toContain("--timeline-clip-selection: #0f766e");
|
|
114
|
+
expect(themeCss).toContain("--timeline-clip-audio-bg: rgba(167, 139, 250, 0.16)");
|
|
115
|
+
expect(themeCss).toContain("--timeline-clip-audio-border: rgba(167, 139, 250, 0.4)");
|
|
116
|
+
expect(themeCss).toContain("--timeline-clip-audio-bg-hover: rgba(167, 139, 250, 0.24)");
|
|
117
|
+
expect(themeCss).toContain("--timeline-clip-audio-bg-dragging: rgba(60, 52, 84, 0.96)");
|
|
118
|
+
expect(audioClipRule).toContain("background-color: var(--timeline-clip-audio-bg)");
|
|
119
|
+
expect(audioClipRule).toContain("border-color: var(--timeline-clip-audio-border)");
|
|
120
|
+
expect(audioClipHoverRule).toContain("background-color: var(--timeline-clip-audio-bg-hover)");
|
|
121
|
+
expect(audioClipDraggingRule).toContain("var(--timeline-clip-audio-bg-dragging)");
|
|
122
|
+
expect(themeCss).toContain("--timeline-clip-selection: rgba(255, 255, 255, 0.85)");
|
|
123
|
+
expect(selectedTimelineClipRule).toContain(
|
|
124
|
+
"outline: 1.5px solid var(--timeline-clip-selection)",
|
|
125
|
+
);
|
|
126
|
+
expect(selectedTimelineClipRule).toContain("outline-offset: -1.5px");
|
|
127
|
+
expect(activeSelectedTimelineClipRule).toContain(
|
|
128
|
+
"outline: 1.5px solid var(--timeline-clip-selection)",
|
|
129
|
+
);
|
|
130
|
+
expect(activeSelectedTimelineClipRule).toContain("outline-offset: -1.5px");
|
|
79
131
|
expect(activeTimelineClipRule).not.toContain("background: linear-gradient");
|
|
80
132
|
expect(activeTimelineClipRule).toContain("border-color: var(--clip-border-active)");
|
|
81
133
|
expect(activeTimelineClipRule).not.toContain("box-shadow");
|
|
@@ -85,6 +137,20 @@ describe("timeline motion styles", () => {
|
|
|
85
137
|
expect(activeBloomOverlayRule).toContain("opacity: 1");
|
|
86
138
|
});
|
|
87
139
|
|
|
140
|
+
it("routes timeline overlay colours through theme tokens", () => {
|
|
141
|
+
const overlaySource = timelineOverlaySources.join("\n");
|
|
142
|
+
|
|
143
|
+
expect(overlaySource).toContain("var(--timeline-shortcut-bg)");
|
|
144
|
+
expect(overlaySource).toContain("var(--timeline-thumbnail-shimmer)");
|
|
145
|
+
expect(overlaySource).toContain("var(--timeline-thumbnail-label-gradient)");
|
|
146
|
+
expect(overlaySource).toContain("var(--timeline-thumbnail-label-shadow)");
|
|
147
|
+
expect(overlaySource).toContain('"--timeline-waveform-bar-rgb"');
|
|
148
|
+
expect(overlaySource).toContain("var(--timeline-waveform-error)");
|
|
149
|
+
expect(overlaySource).toContain("var(--timeline-waveform-label-shadow)");
|
|
150
|
+
expect(overlaySource).toContain("var(--timeline-text-solid)");
|
|
151
|
+
expect(overlaySource).toContain("var(--timeline-accent)");
|
|
152
|
+
});
|
|
153
|
+
|
|
88
154
|
it("targets trim handle bars without changing drag geometry", () => {
|
|
89
155
|
const handleClassMatches = timelineClipSource.match(/className="timeline-clip__handle-bar"/g);
|
|
90
156
|
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
clipWidthLadder,
|
|
4
|
+
defaultTimelineTheme,
|
|
3
5
|
getClipHandleOpacity,
|
|
4
6
|
getRenderedTimelineElement,
|
|
5
7
|
getTimelineTrackStyle,
|
|
6
8
|
} from "./timelineTheme";
|
|
7
9
|
import { getTrackStyle } from "./timelineIcons";
|
|
8
10
|
|
|
11
|
+
describe("clipWidthLadder", () => {
|
|
12
|
+
it("labels from 60px, keeps the picture below that, and uses one frame below 24px", () => {
|
|
13
|
+
expect(clipWidthLadder(200)).toBe("labeled");
|
|
14
|
+
expect(clipWidthLadder(60)).toBe("labeled");
|
|
15
|
+
expect(clipWidthLadder(59)).toBe("picture");
|
|
16
|
+
expect(clipWidthLadder(24)).toBe("picture");
|
|
17
|
+
expect(clipWidthLadder(23)).toBe("frame");
|
|
18
|
+
expect(defaultTimelineTheme.clipRadius).toBe("var(--timeline-clip-radius)");
|
|
19
|
+
expect(defaultTimelineTheme.audioClipRadius).toBe("var(--timeline-clip-audio-radius)");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
9
23
|
describe("getTimelineTrackStyle", () => {
|
|
10
24
|
it("uses one neutral clip style for every timeline tag", () => {
|
|
11
25
|
const expectedStyle = {
|