@hyperframes/studio 0.7.94 → 0.7.95
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-C1o3TZ1w.js} +1 -1
- package/dist/assets/{index-CH_dyqrx.js → index-4KcC7fDo.js} +195 -195
- package/dist/assets/{index-BWh5m2-P.js → index-Bl6x228Z.js} +1 -1
- package/dist/assets/{index-B0NWErIq.js → index-DR4Dsw8D.js} +1 -1
- 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 +2644 -1837
- 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/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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
6
7
|
import { ImageThumbnail } from "./ImageThumbnail";
|
|
7
8
|
|
|
8
9
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
@@ -68,6 +69,7 @@ beforeEach(() => {
|
|
|
68
69
|
afterEach(() => {
|
|
69
70
|
act(() => root?.unmount());
|
|
70
71
|
root = null;
|
|
72
|
+
thumbnailScheduler.invalidateProject("p");
|
|
71
73
|
globalThis.IntersectionObserver = originalIO;
|
|
72
74
|
globalThis.ResizeObserver = originalRO;
|
|
73
75
|
globalThis.Image = originalImage;
|
|
@@ -82,6 +84,10 @@ function render(props: { imageSrc: string; label?: string; labelColor?: string }
|
|
|
82
84
|
imageSrc={props.imageSrc}
|
|
83
85
|
label={props.label ?? ""}
|
|
84
86
|
labelColor={props.labelColor ?? "#fff"}
|
|
87
|
+
projectId="p"
|
|
88
|
+
sessionEpoch={1}
|
|
89
|
+
priority="visible"
|
|
90
|
+
rich={false}
|
|
85
91
|
/>,
|
|
86
92
|
);
|
|
87
93
|
});
|
|
@@ -93,6 +99,13 @@ function lastProbe(): MockImage {
|
|
|
93
99
|
return probe!;
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
async function resolveProbe(update: (probe: MockImage) => void): Promise<void> {
|
|
103
|
+
await act(async () => {
|
|
104
|
+
update(lastProbe());
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
96
109
|
/** Assert at least one tile rendered and the first tile serves `expectedSrc`. */
|
|
97
110
|
function expectFirstTileSrc(expectedSrc: string): void {
|
|
98
111
|
const imgs = [...host.querySelectorAll("img")];
|
|
@@ -107,15 +120,15 @@ describe("ImageThumbnail", () => {
|
|
|
107
120
|
expect(host.querySelectorAll("img").length).toBe(0);
|
|
108
121
|
});
|
|
109
122
|
|
|
110
|
-
it("probes the resolved src and renders repeated object-cover tiles on load", () => {
|
|
123
|
+
it("probes the resolved src and renders repeated object-cover tiles on load", async () => {
|
|
111
124
|
render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
|
|
112
125
|
const probe = lastProbe();
|
|
113
126
|
expect(probe.src).toBe("/api/projects/p/preview/assets/pic.png");
|
|
114
127
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
128
|
+
await resolveProbe((current) => {
|
|
129
|
+
current.naturalWidth = 1920;
|
|
130
|
+
current.naturalHeight = 1080;
|
|
131
|
+
current.onload?.();
|
|
119
132
|
});
|
|
120
133
|
|
|
121
134
|
const imgs = [...host.querySelectorAll("img")];
|
|
@@ -127,18 +140,18 @@ describe("ImageThumbnail", () => {
|
|
|
127
140
|
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
128
141
|
});
|
|
129
142
|
|
|
130
|
-
it("drops the shimmer and renders no tiles when a raster image fails to load", () => {
|
|
143
|
+
it("drops the shimmer and renders no tiles when a raster image fails to load", async () => {
|
|
131
144
|
render({ imageSrc: "/api/projects/p/preview/assets/missing.png" });
|
|
132
|
-
|
|
145
|
+
await resolveProbe((probe) => probe.onerror?.());
|
|
133
146
|
expect(host.querySelectorAll("img").length).toBe(0);
|
|
134
147
|
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
135
148
|
});
|
|
136
149
|
|
|
137
|
-
it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", () => {
|
|
150
|
+
it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", async () => {
|
|
138
151
|
render({ imageSrc: "/api/projects/p/preview/assets/logo.svg" });
|
|
139
152
|
const probe = lastProbe();
|
|
140
153
|
|
|
141
|
-
|
|
154
|
+
await resolveProbe(() => {
|
|
142
155
|
// naturalWidth stays 0 — SVG with no width/height attribute
|
|
143
156
|
probe.onload?.();
|
|
144
157
|
});
|
|
@@ -147,21 +160,20 @@ describe("ImageThumbnail", () => {
|
|
|
147
160
|
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
148
161
|
});
|
|
149
162
|
|
|
150
|
-
it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", () => {
|
|
163
|
+
it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", async () => {
|
|
151
164
|
// Some browser/sandbox environments fire onerror for SVGs even though the
|
|
152
165
|
// <img> element itself can render the file — we must not blank the strip.
|
|
153
166
|
render({ imageSrc: "/api/projects/p/preview/assets/icon.svg" });
|
|
154
167
|
|
|
155
|
-
|
|
168
|
+
await resolveProbe((probe) => probe.onerror?.());
|
|
156
169
|
|
|
157
170
|
expectFirstTileSrc("/api/projects/p/preview/assets/icon.svg");
|
|
158
171
|
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
159
172
|
});
|
|
160
173
|
|
|
161
|
-
it("renders the label above the strip when provided", () => {
|
|
174
|
+
it("renders the label above the strip when provided", async () => {
|
|
162
175
|
render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" });
|
|
163
|
-
|
|
164
|
-
const probe = lastProbe();
|
|
176
|
+
await resolveProbe((probe) => {
|
|
165
177
|
probe.naturalWidth = 100;
|
|
166
178
|
probe.naturalHeight = 100;
|
|
167
179
|
probe.onload?.();
|
|
@@ -1,135 +1,93 @@
|
|
|
1
|
-
import { memo,
|
|
1
|
+
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
-
import {
|
|
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";
|
|
4
7
|
|
|
5
8
|
interface ImageThumbnailProps {
|
|
6
9
|
imageSrc: string;
|
|
7
10
|
label: string;
|
|
8
11
|
labelColor: string;
|
|
12
|
+
projectId?: string;
|
|
13
|
+
sessionEpoch?: number;
|
|
14
|
+
priority?: ThumbnailPriority;
|
|
15
|
+
rich?: boolean;
|
|
9
16
|
}
|
|
10
17
|
|
|
11
|
-
/**
|
|
12
|
-
* Renders a film-strip of a still image for a timeline clip. The image is a
|
|
13
|
-
* fixed-width tile (sized by its natural aspect ratio) repeated to fill the
|
|
14
|
-
* clip width — matching VideoThumbnail's visual pattern. Loading is lazy
|
|
15
|
-
* (IntersectionObserver) with the same shimmer fallback while decoding.
|
|
16
|
-
*/
|
|
18
|
+
/** A scheduler-backed still-image strip. Mounting is the sole work trigger. */
|
|
17
19
|
export const ImageThumbnail = memo(function ImageThumbnail({
|
|
18
20
|
imageSrc,
|
|
19
21
|
label,
|
|
20
22
|
labelColor,
|
|
23
|
+
projectId = imageSrc,
|
|
24
|
+
sessionEpoch = 0,
|
|
25
|
+
priority = "visible",
|
|
26
|
+
rich = false,
|
|
21
27
|
}: ImageThumbnailProps) {
|
|
22
28
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
ioRef.current?.disconnect();
|
|
42
|
-
}
|
|
29
|
+
const observerRef = useRef<ResizeObserver | null>(null);
|
|
30
|
+
const request = useMemo(
|
|
31
|
+
() => ({
|
|
32
|
+
key: createThumbnailKey({ kind: "image", source: imageSrc, rich: Number(rich) }),
|
|
33
|
+
projectId,
|
|
34
|
+
sessionEpoch,
|
|
35
|
+
kind: "image" as const,
|
|
36
|
+
priority,
|
|
37
|
+
rich,
|
|
38
|
+
load: async (signal: AbortSignal) => {
|
|
39
|
+
const aspect = await probeImageAspect(imageSrc, signal, true);
|
|
40
|
+
return {
|
|
41
|
+
value: { kind: "image" as const, url: imageSrc, aspect },
|
|
42
|
+
weight:
|
|
43
|
+
TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth *
|
|
44
|
+
TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight *
|
|
45
|
+
4,
|
|
46
|
+
};
|
|
43
47
|
},
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
}),
|
|
49
|
+
[imageSrc, priority, projectId, rich, sessionEpoch],
|
|
50
|
+
);
|
|
51
|
+
const snapshot = useThumbnailLease(request);
|
|
52
|
+
const value = snapshot.status === "ready" ? snapshot.value : null;
|
|
53
|
+
const aspect = value?.kind === "image" ? value.aspect : 16 / 9;
|
|
54
|
+
const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect);
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
const setContainerRef = useCallback((element: HTMLDivElement | null) => {
|
|
57
|
+
observerRef.current?.disconnect();
|
|
58
|
+
if (!element) return;
|
|
59
|
+
const target = element.parentElement ?? element;
|
|
60
|
+
setContainerWidth(target.clientWidth);
|
|
61
|
+
observerRef.current = new ResizeObserver(([entry]) =>
|
|
62
|
+
setContainerWidth(entry.contentRect.width),
|
|
63
|
+
);
|
|
64
|
+
observerRef.current.observe(target);
|
|
54
65
|
}, []);
|
|
55
66
|
|
|
56
|
-
useMountEffect(() => () =>
|
|
57
|
-
ioRef.current?.disconnect();
|
|
58
|
-
roRef.current?.disconnect();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// Probe the image once visible — measures the natural aspect ratio so the
|
|
62
|
-
// tile width matches, and flips to the error state (plain clip background)
|
|
63
|
-
// if the src can't load. The browser cache makes the tile <img>s free.
|
|
64
|
-
//
|
|
65
|
-
// SVG handling: SVGs without intrinsic width/height report naturalWidth=0 on
|
|
66
|
-
// load (treat as success with the 16:9 default aspect) and may fire onerror
|
|
67
|
-
// in some environments even though the file is valid and can be displayed —
|
|
68
|
-
// fall back to loaded-at-16:9 rather than hiding the strip entirely.
|
|
69
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
if (!visible) return;
|
|
72
|
-
let cancelled = false;
|
|
73
|
-
setStatus("loading");
|
|
74
|
-
|
|
75
|
-
const isSvg = /\.svg($|\?)/i.test(imageSrc);
|
|
76
|
-
|
|
77
|
-
const probe = new Image();
|
|
78
|
-
probe.onload = () => {
|
|
79
|
-
if (cancelled) return;
|
|
80
|
-
if (probe.naturalWidth > 0 && probe.naturalHeight > 0) {
|
|
81
|
-
setAspect(probe.naturalWidth / probe.naturalHeight);
|
|
82
|
-
}
|
|
83
|
-
// naturalWidth===0 (e.g. SVG with no intrinsic dimensions) falls through
|
|
84
|
-
// to "loaded" with the default 16:9 aspect already set in state.
|
|
85
|
-
setStatus("loaded");
|
|
86
|
-
};
|
|
87
|
-
probe.onerror = () => {
|
|
88
|
-
if (cancelled) return;
|
|
89
|
-
// SVGs can fail the probe in certain browser/sandbox environments even
|
|
90
|
-
// though the <img> tiles themselves render fine (different security
|
|
91
|
-
// context). Show the strip at the 16:9 fallback rather than blanking.
|
|
92
|
-
if (isSvg) {
|
|
93
|
-
setStatus("loaded");
|
|
94
|
-
} else {
|
|
95
|
-
setStatus("error");
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
probe.src = imageSrc;
|
|
99
|
-
|
|
100
|
-
return () => {
|
|
101
|
-
cancelled = true;
|
|
102
|
-
probe.onload = null;
|
|
103
|
-
probe.onerror = null;
|
|
104
|
-
probe.src = "";
|
|
105
|
-
};
|
|
106
|
-
}, [visible, imageSrc]);
|
|
107
|
-
|
|
108
|
-
const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect);
|
|
67
|
+
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
109
68
|
|
|
110
69
|
return (
|
|
111
70
|
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
112
|
-
{
|
|
71
|
+
{value?.kind === "image" && (
|
|
113
72
|
<div className="absolute inset-0 flex">
|
|
114
|
-
{Array.from({ length: frameCount }
|
|
73
|
+
{Array.from({ length: frameCount }, (_, index) => (
|
|
115
74
|
<div
|
|
116
|
-
key={
|
|
117
|
-
className="flex-shrink-0
|
|
75
|
+
key={index}
|
|
76
|
+
className="relative h-full flex-shrink-0 overflow-hidden bg-neutral-900"
|
|
118
77
|
style={{ width: frameW }}
|
|
119
78
|
>
|
|
120
79
|
<img
|
|
121
|
-
src={
|
|
80
|
+
src={value.url}
|
|
122
81
|
alt=""
|
|
123
82
|
draggable={false}
|
|
124
83
|
loading="lazy"
|
|
125
|
-
className="absolute inset-0
|
|
84
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
126
85
|
/>
|
|
127
86
|
</div>
|
|
128
87
|
))}
|
|
129
88
|
</div>
|
|
130
89
|
)}
|
|
131
|
-
|
|
132
|
-
{visible && status === "loading" && (
|
|
90
|
+
{snapshot.status === "loading" && (
|
|
133
91
|
<div
|
|
134
92
|
className="absolute inset-0 animate-pulse"
|
|
135
93
|
style={{
|
|
@@ -138,17 +96,16 @@ export const ImageThumbnail = memo(function ImageThumbnail({
|
|
|
138
96
|
}}
|
|
139
97
|
/>
|
|
140
98
|
)}
|
|
141
|
-
|
|
142
99
|
{label && (
|
|
143
100
|
<div
|
|
144
|
-
className="absolute
|
|
101
|
+
className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
|
|
145
102
|
style={{
|
|
146
103
|
background:
|
|
147
104
|
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
|
148
105
|
}}
|
|
149
106
|
>
|
|
150
107
|
<span
|
|
151
|
-
className="text-[9px] font-semibold
|
|
108
|
+
className="block truncate text-[9px] font-semibold leading-tight"
|
|
152
109
|
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
|
153
110
|
>
|
|
154
111
|
{label}
|
|
@@ -64,7 +64,6 @@ export {
|
|
|
64
64
|
getTimelineScrollTopForGeometryChange,
|
|
65
65
|
getTimelineVisibleTimeRange,
|
|
66
66
|
} from "./timelineViewportGeometry";
|
|
67
|
-
|
|
68
67
|
export const Timeline = memo(function Timeline({
|
|
69
68
|
onSeek,
|
|
70
69
|
onDrillDown,
|
|
@@ -317,24 +316,25 @@ export const Timeline = memo(function Timeline({
|
|
|
317
316
|
toggleSelectedKeyframe,
|
|
318
317
|
});
|
|
319
318
|
|
|
320
|
-
const { clipIndex, renderTimeRange, pinnedClipIdentities } =
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
319
|
+
const { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities } =
|
|
320
|
+
useTimelineClipRenderWindow({
|
|
321
|
+
tracks,
|
|
322
|
+
viewport,
|
|
323
|
+
pixelsPerSecond: pps,
|
|
324
|
+
contentOrigin,
|
|
325
|
+
duration: displayDuration,
|
|
326
|
+
selectedElementId: selectedElementId ?? undefined,
|
|
327
|
+
draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
|
|
328
|
+
resizingElementIds,
|
|
329
|
+
focusedElementId: timelineFocus.pinnedElementId,
|
|
330
|
+
focusedEaseElementId: focusedEaseSegment?.elementId,
|
|
331
|
+
clipContextMenuElementId: clipContextMenu
|
|
332
|
+
? getTimelineElementIdentity(clipContextMenu.element)
|
|
333
|
+
: undefined,
|
|
334
|
+
keyframeContextMenuElementId: kfContextMenu
|
|
335
|
+
? getTimelineElementIdentity(kfContextMenu.element)
|
|
336
|
+
: undefined,
|
|
337
|
+
});
|
|
338
338
|
useTimelineActiveClips({
|
|
339
339
|
scrollRef,
|
|
340
340
|
currentTime,
|
|
@@ -504,6 +504,7 @@ export const Timeline = memo(function Timeline({
|
|
|
504
504
|
rowsVirtualized={timelineFocus.rowVirtualizationActive}
|
|
505
505
|
clipIndex={clipIndex}
|
|
506
506
|
renderTimeRange={renderTimeRange}
|
|
507
|
+
visibleTimeRange={visibleTimeRange}
|
|
507
508
|
pinnedClipIdentities={pinnedClipIdentities}
|
|
508
509
|
trackOrder={trackOrder}
|
|
509
510
|
tracks={tracks}
|
|
@@ -9,6 +9,7 @@ import { getTimelineDragOverlayPosition } from "./timelineClipDragPreview";
|
|
|
9
9
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
10
10
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
11
11
|
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
|
12
|
+
import type { TimelineClipRenderContext } from "./TimelineTypes";
|
|
12
13
|
|
|
13
14
|
interface TimelineGestureOverlayProps {
|
|
14
15
|
drag: DraggedClipState | null;
|
|
@@ -22,6 +23,7 @@ interface TimelineGestureOverlayProps {
|
|
|
22
23
|
renderClipContent?: (
|
|
23
24
|
element: TimelineElement,
|
|
24
25
|
style: { clip: string; label: string },
|
|
26
|
+
context: TimelineClipRenderContext,
|
|
25
27
|
) => ReactNode;
|
|
26
28
|
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
27
29
|
}
|
|
@@ -87,6 +89,7 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
|
|
|
87
89
|
getTrackStyle(element.tag),
|
|
88
90
|
renderClipContent,
|
|
89
91
|
renderClipOverlay,
|
|
92
|
+
{ priority: "interaction", rich: true },
|
|
90
93
|
)}
|
|
91
94
|
</TimelineClip>
|
|
92
95
|
</div>
|
|
@@ -76,10 +76,14 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
76
76
|
host: HTMLDivElement;
|
|
77
77
|
root: Root;
|
|
78
78
|
rerender: (next: RenderLanesOptions) => void;
|
|
79
|
+
setSelectedElementId: ReturnType<typeof vi.fn>;
|
|
80
|
+
onSelectElement: ReturnType<typeof vi.fn>;
|
|
79
81
|
} {
|
|
80
82
|
const host = document.createElement("div");
|
|
81
83
|
document.body.append(host);
|
|
82
84
|
const root = createRoot(host);
|
|
85
|
+
const setSelectedElementId = vi.fn();
|
|
86
|
+
const onSelectElement = vi.fn();
|
|
83
87
|
const render = (next: RenderLanesOptions) => {
|
|
84
88
|
const elements = next.elements ?? [element("clip-a", TRACK_A)];
|
|
85
89
|
const gsapAnimations = next.animations ?? new Map<string, GsapAnimation[]>();
|
|
@@ -118,6 +122,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
118
122
|
})}
|
|
119
123
|
clipIndex={createTimelineClipIndex(tracks)}
|
|
120
124
|
renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
|
|
125
|
+
visibleTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
|
|
121
126
|
pinnedClipIdentities={new Set()}
|
|
122
127
|
trackOrder={displayTrackOrder}
|
|
123
128
|
tracks={tracks}
|
|
@@ -137,7 +142,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
137
142
|
setRangeSelection={vi.fn()}
|
|
138
143
|
setResizingClip={vi.fn()}
|
|
139
144
|
setDraggedClip={vi.fn()}
|
|
140
|
-
setSelectedElementId={
|
|
145
|
+
setSelectedElementId={setSelectedElementId}
|
|
141
146
|
shiftClickClipRef={createRef()}
|
|
142
147
|
getPreviewElement={(el) => el}
|
|
143
148
|
getTrackStyle={getTrackStyle}
|
|
@@ -149,6 +154,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
149
154
|
onTogglePropertyGroupKeyframe={vi.fn()}
|
|
150
155
|
onResizeElement={vi.fn()}
|
|
151
156
|
onMoveElement={vi.fn()}
|
|
157
|
+
onSelectElement={onSelectElement}
|
|
152
158
|
onRazorSplit={vi.fn()}
|
|
153
159
|
onRazorSplitAll={vi.fn()}
|
|
154
160
|
/>,
|
|
@@ -156,7 +162,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
156
162
|
});
|
|
157
163
|
};
|
|
158
164
|
render(options);
|
|
159
|
-
return { host, root, rerender: render };
|
|
165
|
+
return { host, root, rerender: render, setSelectedElementId, onSelectElement };
|
|
160
166
|
}
|
|
161
167
|
|
|
162
168
|
function visibilityLabels(host: HTMLElement): (string | null)[] {
|
|
@@ -347,3 +353,19 @@ describe("TimelineLanes disclosure target", () => {
|
|
|
347
353
|
act(() => view.root.unmount());
|
|
348
354
|
});
|
|
349
355
|
});
|
|
356
|
+
|
|
357
|
+
describe("TimelineLanes selection", () => {
|
|
358
|
+
it("keeps a selected clip selected when it is clicked again", () => {
|
|
359
|
+
const selected = element("clip-a", TRACK_A);
|
|
360
|
+
const view = renderLanes({
|
|
361
|
+
elements: [selected],
|
|
362
|
+
selectedElementIds: new Set([selected.id]),
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
act(() => view.host.querySelector<HTMLButtonElement>('[data-el-id="clip-a"]')?.click());
|
|
366
|
+
|
|
367
|
+
expect(view.setSelectedElementId).toHaveBeenCalledWith(selected.id);
|
|
368
|
+
expect(view.onSelectElement).toHaveBeenCalledWith(selected);
|
|
369
|
+
act(() => view.root.unmount());
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -22,7 +22,7 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
|
22
22
|
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
|
23
23
|
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
24
24
|
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
|
25
|
-
import { renderClipChildren } from "./timelineClipChildren";
|
|
25
|
+
import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
|
|
26
26
|
import { TimelineTrackRow } from "./TimelineTrackRow";
|
|
27
27
|
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
|
28
28
|
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
@@ -57,6 +57,7 @@ export function TimelineLanes({
|
|
|
57
57
|
rowsVirtualized,
|
|
58
58
|
clipIndex,
|
|
59
59
|
renderTimeRange,
|
|
60
|
+
visibleTimeRange,
|
|
60
61
|
pinnedClipIdentities,
|
|
61
62
|
trackOrder,
|
|
62
63
|
tracks,
|
|
@@ -323,8 +324,7 @@ export function TimelineLanes({
|
|
|
323
324
|
const isSelected =
|
|
324
325
|
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
|
325
326
|
const isComposition = !!el.compositionSrc;
|
|
326
|
-
//
|
|
327
|
-
// index, or a splice/reorder remounts every clip at/after the change.
|
|
327
|
+
// Element identity stays stable across clip splices and reorders.
|
|
328
328
|
const clipKey = elementKey;
|
|
329
329
|
const isDraggingClip =
|
|
330
330
|
draggedClip?.started === true &&
|
|
@@ -332,6 +332,11 @@ export function TimelineLanes({
|
|
|
332
332
|
getTimelineElementIdentity(draggedElement) === elementKey;
|
|
333
333
|
if (isDraggingClip) return null;
|
|
334
334
|
const previewElement = getPreviewElement(el);
|
|
335
|
+
const renderContext = resolveClipRenderContext(
|
|
336
|
+
previewElement,
|
|
337
|
+
visibleTimeRange,
|
|
338
|
+
isSelected || hoveredClip === clipKey || pinnedClipIdentities.has(clipKey),
|
|
339
|
+
);
|
|
335
340
|
// Passenger of a live multi-drag: preserve the formation without changing
|
|
336
341
|
// the passenger's timeline data until the owning drag commits.
|
|
337
342
|
const isPassenger =
|
|
@@ -475,15 +480,9 @@ export function TimelineLanes({
|
|
|
475
480
|
}
|
|
476
481
|
return;
|
|
477
482
|
}
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const hadMultiSelection = selectedElementIds.size > 0;
|
|
482
|
-
usePlayerStore.getState().clearSelectedElementIds();
|
|
483
|
-
const nextElement =
|
|
484
|
-
selectedElementId === elementKey && !hadMultiSelection ? null : el;
|
|
485
|
-
setSelectedElementId(nextElement ? elementKey : null);
|
|
486
|
-
onSelectElement?.(nextElement);
|
|
483
|
+
// Clip selection is idempotent; empty timeline space owns deselection.
|
|
484
|
+
setSelectedElementId(elementKey);
|
|
485
|
+
onSelectElement?.(el);
|
|
487
486
|
}
|
|
488
487
|
}
|
|
489
488
|
onDoubleClick={(e) => {
|
|
@@ -497,6 +496,7 @@ export function TimelineLanes({
|
|
|
497
496
|
clipStyle,
|
|
498
497
|
renderClipContent,
|
|
499
498
|
renderClipOverlay,
|
|
499
|
+
renderContext,
|
|
500
500
|
)}
|
|
501
501
|
</TimelineClip>
|
|
502
502
|
);
|
|
@@ -4,6 +4,11 @@ import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
|
4
4
|
import type { TimelineTheme } from "./timelineTheme";
|
|
5
5
|
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
|
|
6
6
|
|
|
7
|
+
export interface TimelineClipRenderContext {
|
|
8
|
+
priority: "overscan" | "visible" | "interaction";
|
|
9
|
+
rich: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
7
12
|
export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
|
|
8
13
|
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
|
|
9
14
|
sessionEpoch?: number;
|
|
@@ -12,6 +17,7 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverri
|
|
|
12
17
|
renderClipContent?: (
|
|
13
18
|
element: TimelineElement,
|
|
14
19
|
style: { clip: string; label: string },
|
|
20
|
+
context: TimelineClipRenderContext,
|
|
15
21
|
) => ReactNode;
|
|
16
22
|
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
17
23
|
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|