@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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { ThumbnailScheduler, type ThumbnailRequest } from "../player/lib/thumbnailScheduler";
|
|
7
|
+
import { useThumbnailLease } from "./useThumbnailLease";
|
|
8
|
+
|
|
9
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("useThumbnailLease", () => {
|
|
16
|
+
it("subscribes once, publishes the result, and releases on unmount", async () => {
|
|
17
|
+
const scheduler = new ThumbnailScheduler();
|
|
18
|
+
const load = vi.fn(async () => ({
|
|
19
|
+
value: { kind: "image" as const, url: "blob:poster", aspect: 16 / 9 },
|
|
20
|
+
weight: 10,
|
|
21
|
+
}));
|
|
22
|
+
const request: ThumbnailRequest = {
|
|
23
|
+
key: "poster",
|
|
24
|
+
projectId: "demo",
|
|
25
|
+
sessionEpoch: 1,
|
|
26
|
+
kind: "image",
|
|
27
|
+
priority: "visible",
|
|
28
|
+
load,
|
|
29
|
+
};
|
|
30
|
+
let status = "missing";
|
|
31
|
+
|
|
32
|
+
function Probe() {
|
|
33
|
+
status = useThumbnailLease(request, scheduler).status;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const root = createRoot(document.createElement("div"));
|
|
38
|
+
await act(async () => {
|
|
39
|
+
root.render(React.createElement(Probe));
|
|
40
|
+
await Promise.resolve();
|
|
41
|
+
});
|
|
42
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(status).toBe("ready");
|
|
44
|
+
expect(scheduler.getDiagnostics().leases).toBe(1);
|
|
45
|
+
|
|
46
|
+
act(() => root.unmount());
|
|
47
|
+
expect(scheduler.getDiagnostics().leases).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not acquire work for a null request", () => {
|
|
51
|
+
const scheduler = new ThumbnailScheduler();
|
|
52
|
+
let status = "missing";
|
|
53
|
+
function Probe() {
|
|
54
|
+
status = useThumbnailLease(null, scheduler).status;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const root = createRoot(document.createElement("div"));
|
|
58
|
+
act(() => root.render(React.createElement(Probe)));
|
|
59
|
+
expect(status).toBe("idle");
|
|
60
|
+
expect(scheduler.getDiagnostics().leases).toBe(0);
|
|
61
|
+
act(() => root.unmount());
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("updates priority without restarting the active request", async () => {
|
|
65
|
+
const scheduler = new ThumbnailScheduler();
|
|
66
|
+
let resolve!: (value: {
|
|
67
|
+
value: { kind: "image"; url: string; aspect: number };
|
|
68
|
+
weight: number;
|
|
69
|
+
}) => void;
|
|
70
|
+
const pending = new Promise<{
|
|
71
|
+
value: { kind: "image"; url: string; aspect: number };
|
|
72
|
+
weight: number;
|
|
73
|
+
}>((accept) => {
|
|
74
|
+
resolve = accept;
|
|
75
|
+
});
|
|
76
|
+
const load = vi.fn(() => pending);
|
|
77
|
+
let priority: ThumbnailRequest["priority"] = "overscan";
|
|
78
|
+
function Probe() {
|
|
79
|
+
useThumbnailLease(
|
|
80
|
+
{
|
|
81
|
+
key: "same-content",
|
|
82
|
+
projectId: "demo",
|
|
83
|
+
sessionEpoch: 1,
|
|
84
|
+
kind: "image",
|
|
85
|
+
priority,
|
|
86
|
+
load,
|
|
87
|
+
},
|
|
88
|
+
scheduler,
|
|
89
|
+
);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const root = createRoot(document.createElement("div"));
|
|
93
|
+
act(() => root.render(React.createElement(Probe)));
|
|
94
|
+
priority = "interaction";
|
|
95
|
+
act(() => root.render(React.createElement(Probe)));
|
|
96
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
97
|
+
|
|
98
|
+
await act(async () => {
|
|
99
|
+
resolve({ value: { kind: "image", url: "blob:done", aspect: 1 }, weight: 1 });
|
|
100
|
+
await pending;
|
|
101
|
+
});
|
|
102
|
+
act(() => root.unmount());
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("resubscribes when the request work shape changes", async () => {
|
|
106
|
+
const scheduler = new ThumbnailScheduler();
|
|
107
|
+
const imageLoad = vi.fn(async () => ({
|
|
108
|
+
value: { kind: "image" as const, url: "blob:image", aspect: 1 },
|
|
109
|
+
weight: 1,
|
|
110
|
+
}));
|
|
111
|
+
const videoLoad = vi.fn(async () => ({
|
|
112
|
+
value: { kind: "image" as const, url: "blob:video", aspect: 1 },
|
|
113
|
+
weight: 1,
|
|
114
|
+
}));
|
|
115
|
+
let kind: ThumbnailRequest["kind"] = "image";
|
|
116
|
+
let rich = false;
|
|
117
|
+
function Probe() {
|
|
118
|
+
useThumbnailLease(
|
|
119
|
+
{
|
|
120
|
+
key: "same-content",
|
|
121
|
+
projectId: "demo",
|
|
122
|
+
sessionEpoch: 1,
|
|
123
|
+
kind,
|
|
124
|
+
rich,
|
|
125
|
+
priority: "visible",
|
|
126
|
+
load: kind === "image" ? imageLoad : videoLoad,
|
|
127
|
+
},
|
|
128
|
+
scheduler,
|
|
129
|
+
);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const root = createRoot(document.createElement("div"));
|
|
133
|
+
await act(async () => {
|
|
134
|
+
root.render(React.createElement(Probe));
|
|
135
|
+
await Promise.resolve();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
kind = "video";
|
|
139
|
+
rich = true;
|
|
140
|
+
await act(async () => {
|
|
141
|
+
root.render(React.createElement(Probe));
|
|
142
|
+
await Promise.resolve();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(imageLoad).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(videoLoad).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(scheduler.getDiagnostics().leases).toBe(1);
|
|
148
|
+
act(() => root.unmount());
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import {
|
|
3
|
+
createThumbnailRequestIdentity,
|
|
4
|
+
thumbnailScheduler,
|
|
5
|
+
type ThumbnailRequest,
|
|
6
|
+
type ThumbnailScheduler,
|
|
7
|
+
type ThumbnailSnapshot,
|
|
8
|
+
} from "../player/lib/thumbnailScheduler";
|
|
9
|
+
|
|
10
|
+
const IDLE: ThumbnailSnapshot = Object.freeze({ status: "idle" });
|
|
11
|
+
|
|
12
|
+
export function useThumbnailLease(
|
|
13
|
+
request: ThumbnailRequest | null,
|
|
14
|
+
scheduler: ThumbnailScheduler = thumbnailScheduler,
|
|
15
|
+
): ThumbnailSnapshot {
|
|
16
|
+
const requestRef = useRef(request);
|
|
17
|
+
requestRef.current = request;
|
|
18
|
+
const leaseRef = useRef<ReturnType<ThumbnailScheduler["acquire"]> | null>(null);
|
|
19
|
+
const identity = request ? createThumbnailRequestIdentity(request) : null;
|
|
20
|
+
const priority = request?.priority;
|
|
21
|
+
const subscribe = useCallback(
|
|
22
|
+
(listener: () => void) => {
|
|
23
|
+
const current = requestRef.current;
|
|
24
|
+
if (!current || identity === null) return () => {};
|
|
25
|
+
const lease = scheduler.acquire(current, listener);
|
|
26
|
+
leaseRef.current = lease;
|
|
27
|
+
return () => {
|
|
28
|
+
if (leaseRef.current === lease) leaseRef.current = null;
|
|
29
|
+
lease.release();
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
[identity, scheduler],
|
|
33
|
+
);
|
|
34
|
+
const getSnapshot = useCallback(() => {
|
|
35
|
+
const current = requestRef.current;
|
|
36
|
+
return current && identity !== null ? scheduler.getSnapshot(current) : IDLE;
|
|
37
|
+
}, [identity, scheduler]);
|
|
38
|
+
|
|
39
|
+
useLayoutEffect(() => {
|
|
40
|
+
if (priority) leaseRef.current?.updatePriority(priority);
|
|
41
|
+
}, [priority]);
|
|
42
|
+
|
|
43
|
+
return useSyncExternalStore(subscribe, getSnapshot, () => IDLE);
|
|
44
|
+
}
|
|
@@ -24,6 +24,7 @@ interface HarnessProps {
|
|
|
24
24
|
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
|
25
25
|
) => void;
|
|
26
26
|
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
|
27
|
+
onSelectionNotFound: () => void;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
afterEach(() => {
|
|
@@ -67,8 +68,8 @@ function makeSyncFixture() {
|
|
|
67
68
|
const firstSelection = makeSelection("First", firstElement);
|
|
68
69
|
const secondSelection = makeSelection("Second", secondElement);
|
|
69
70
|
const timelineElements: TimelineElement[] = [
|
|
70
|
-
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
|
71
|
-
{ id: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
|
|
71
|
+
{ id: "clip-1", domId: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
|
72
|
+
{ id: "clip-2", domId: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
|
|
72
73
|
];
|
|
73
74
|
const selectionById = new Map([
|
|
74
75
|
["clip-1", firstSelection],
|
|
@@ -96,6 +97,7 @@ describe("useTimelineSelectionPreviewSync", () => {
|
|
|
96
97
|
buildDomSelectionForTimelineElement,
|
|
97
98
|
applyDomSelection,
|
|
98
99
|
applyMarqueeSelection,
|
|
100
|
+
onSelectionNotFound: vi.fn(),
|
|
99
101
|
});
|
|
100
102
|
|
|
101
103
|
expect(applyMarqueeSelection).toHaveBeenCalledWith([secondSelection, firstSelection], false);
|
|
@@ -108,6 +110,21 @@ describe("useTimelineSelectionPreviewSync", () => {
|
|
|
108
110
|
const applyDomSelection = vi.fn();
|
|
109
111
|
const applyMarqueeSelection = vi.fn();
|
|
110
112
|
const harness = renderHarness();
|
|
113
|
+
const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
|
|
114
|
+
return selectionById.get(element.id) ?? null;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await harness.rerender({
|
|
118
|
+
selectedElementId: "clip-1",
|
|
119
|
+
selectedElementIds: new Set(["clip-1"]),
|
|
120
|
+
timelineElements,
|
|
121
|
+
domEditSelection: firstSelection,
|
|
122
|
+
domEditGroupSelections: [firstSelection],
|
|
123
|
+
buildDomSelectionForTimelineElement,
|
|
124
|
+
applyDomSelection,
|
|
125
|
+
applyMarqueeSelection,
|
|
126
|
+
onSelectionNotFound: vi.fn(),
|
|
127
|
+
});
|
|
111
128
|
|
|
112
129
|
await harness.rerender({
|
|
113
130
|
selectedElementId: null,
|
|
@@ -115,15 +132,72 @@ describe("useTimelineSelectionPreviewSync", () => {
|
|
|
115
132
|
timelineElements,
|
|
116
133
|
domEditSelection: firstSelection,
|
|
117
134
|
domEditGroupSelections: [firstSelection],
|
|
118
|
-
buildDomSelectionForTimelineElement
|
|
119
|
-
return selectionById.get(element.id) ?? null;
|
|
120
|
-
}),
|
|
135
|
+
buildDomSelectionForTimelineElement,
|
|
121
136
|
applyDomSelection,
|
|
122
137
|
applyMarqueeSelection,
|
|
138
|
+
onSelectionNotFound: vi.fn(),
|
|
123
139
|
});
|
|
124
140
|
|
|
125
141
|
expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
|
|
126
142
|
expect(applyMarqueeSelection).not.toHaveBeenCalled();
|
|
127
143
|
harness.cleanup();
|
|
128
144
|
});
|
|
145
|
+
|
|
146
|
+
it("warns once while retrying a timeline selection after preview refreshes", async () => {
|
|
147
|
+
const { secondSelection, timelineElements } = makeSyncFixture();
|
|
148
|
+
const applyDomSelection = vi.fn();
|
|
149
|
+
const applyMarqueeSelection = vi.fn();
|
|
150
|
+
const onSelectionNotFound = vi.fn();
|
|
151
|
+
let previewReady = false;
|
|
152
|
+
const buildDomSelectionForTimelineElement = vi.fn(async () =>
|
|
153
|
+
previewReady ? secondSelection : null,
|
|
154
|
+
);
|
|
155
|
+
const selectedElementIds = new Set(["clip-2"]);
|
|
156
|
+
const harness = renderHarness();
|
|
157
|
+
|
|
158
|
+
await harness.rerender({
|
|
159
|
+
selectedElementId: "clip-2",
|
|
160
|
+
selectedElementIds,
|
|
161
|
+
timelineElements,
|
|
162
|
+
domEditSelection: null,
|
|
163
|
+
domEditGroupSelections: [],
|
|
164
|
+
buildDomSelectionForTimelineElement,
|
|
165
|
+
applyDomSelection,
|
|
166
|
+
applyMarqueeSelection,
|
|
167
|
+
onSelectionNotFound,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(onSelectionNotFound).toHaveBeenCalledOnce();
|
|
171
|
+
expect(applyDomSelection).not.toHaveBeenCalled();
|
|
172
|
+
|
|
173
|
+
await harness.rerender({
|
|
174
|
+
selectedElementId: "clip-2",
|
|
175
|
+
selectedElementIds,
|
|
176
|
+
timelineElements: [...timelineElements],
|
|
177
|
+
domEditSelection: null,
|
|
178
|
+
domEditGroupSelections: [],
|
|
179
|
+
buildDomSelectionForTimelineElement,
|
|
180
|
+
applyDomSelection,
|
|
181
|
+
applyMarqueeSelection,
|
|
182
|
+
onSelectionNotFound,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(onSelectionNotFound).toHaveBeenCalledOnce();
|
|
186
|
+
|
|
187
|
+
previewReady = true;
|
|
188
|
+
await harness.rerender({
|
|
189
|
+
selectedElementId: "clip-2",
|
|
190
|
+
selectedElementIds,
|
|
191
|
+
timelineElements: [...timelineElements],
|
|
192
|
+
domEditSelection: null,
|
|
193
|
+
domEditGroupSelections: [],
|
|
194
|
+
buildDomSelectionForTimelineElement,
|
|
195
|
+
applyDomSelection,
|
|
196
|
+
applyMarqueeSelection,
|
|
197
|
+
onSelectionNotFound,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(applyDomSelection).toHaveBeenCalledWith(secondSelection);
|
|
201
|
+
harness.cleanup();
|
|
202
|
+
});
|
|
129
203
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
4
|
import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
|
|
@@ -18,6 +18,7 @@ interface UseTimelineSelectionPreviewSyncParams {
|
|
|
18
18
|
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
|
19
19
|
) => void;
|
|
20
20
|
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
|
21
|
+
onSelectionNotFound: () => void;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
function orderSelectedIds(ids: Set<string>, anchor: string | null): string[] {
|
|
@@ -56,34 +57,51 @@ export function useTimelineSelectionPreviewSync({
|
|
|
56
57
|
buildDomSelectionForTimelineElement,
|
|
57
58
|
applyDomSelection,
|
|
58
59
|
applyMarqueeSelection,
|
|
60
|
+
onSelectionNotFound,
|
|
59
61
|
}: UseTimelineSelectionPreviewSyncParams): void {
|
|
60
62
|
const selectedIds = useMemo(
|
|
61
63
|
() => orderSelectedIds(selectedElementIds, selectedElementId),
|
|
62
64
|
[selectedElementId, selectedElementIds],
|
|
63
65
|
);
|
|
64
66
|
const selectedKey = selectedIds.join("\0");
|
|
67
|
+
const domEditSelectionRef = useRef(domEditSelection);
|
|
68
|
+
const domEditGroupSelectionsRef = useRef(domEditGroupSelections);
|
|
69
|
+
const lastSyncedSelectedKeyRef = useRef("");
|
|
70
|
+
const missingSelectionKeyRef = useRef("");
|
|
71
|
+
domEditSelectionRef.current = domEditSelection;
|
|
72
|
+
domEditGroupSelectionsRef.current = domEditGroupSelections;
|
|
65
73
|
|
|
66
74
|
useEffect(() => {
|
|
75
|
+
const previousSelectedKey = lastSyncedSelectedKeyRef.current;
|
|
76
|
+
lastSyncedSelectedKeyRef.current = selectedKey;
|
|
77
|
+
const currentDomEditSelection = domEditSelectionRef.current;
|
|
78
|
+
const currentDomEditGroupSelections = domEditGroupSelectionsRef.current;
|
|
67
79
|
const currentSelections =
|
|
68
|
-
|
|
69
|
-
?
|
|
70
|
-
:
|
|
71
|
-
? [
|
|
80
|
+
currentDomEditGroupSelections.length > 1
|
|
81
|
+
? currentDomEditGroupSelections
|
|
82
|
+
: currentDomEditSelection
|
|
83
|
+
? [currentDomEditSelection]
|
|
72
84
|
: [];
|
|
73
85
|
const currentIds = currentSelections
|
|
74
86
|
.map((selection) =>
|
|
75
87
|
resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
|
|
76
88
|
)
|
|
77
89
|
.filter((id): id is string => Boolean(id));
|
|
78
|
-
const currentAnchor =
|
|
79
|
-
? resolveTimelineIdForSelection(
|
|
90
|
+
const currentAnchor = currentDomEditSelection
|
|
91
|
+
? resolveTimelineIdForSelection(currentDomEditSelection, timelineElements, activeCompPath)
|
|
80
92
|
: null;
|
|
81
93
|
|
|
82
94
|
if (selectedIds.length === 0) {
|
|
83
|
-
|
|
95
|
+
missingSelectionKeyRef.current = "";
|
|
96
|
+
if (previousSelectedKey.length > 0 && currentIds.length > 0) {
|
|
97
|
+
applyDomSelection(null, { revealPanel: false });
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (selectionIdsMatch(currentIds, selectedIds, currentAnchor, selectedElementId)) {
|
|
102
|
+
missingSelectionKeyRef.current = "";
|
|
84
103
|
return;
|
|
85
104
|
}
|
|
86
|
-
if (selectionIdsMatch(currentIds, selectedIds, currentAnchor, selectedElementId)) return;
|
|
87
105
|
|
|
88
106
|
let cancelled = false;
|
|
89
107
|
const syncSelection = async () => {
|
|
@@ -101,11 +119,18 @@ export function useTimelineSelectionPreviewSync({
|
|
|
101
119
|
// shrunk set back and silently drop the members whose DOM node was not ready.
|
|
102
120
|
// Bail instead; a later effect run (on timelineElements/DOM change) applies the
|
|
103
121
|
// full set once every resolvable member has a live node.
|
|
104
|
-
if (selections.length < resolvableCount)
|
|
122
|
+
if (selections.length < resolvableCount) {
|
|
123
|
+
if (missingSelectionKeyRef.current !== selectedKey) {
|
|
124
|
+
missingSelectionKeyRef.current = selectedKey;
|
|
125
|
+
onSelectionNotFound();
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
missingSelectionKeyRef.current = "";
|
|
105
130
|
if (selections.length === 0) {
|
|
106
131
|
applyDomSelection(null, { revealPanel: false });
|
|
107
132
|
} else if (selections.length === 1) {
|
|
108
|
-
applyDomSelection(selections[0]
|
|
133
|
+
applyDomSelection(selections[0]);
|
|
109
134
|
} else {
|
|
110
135
|
applyMarqueeSelection(selections, false);
|
|
111
136
|
}
|
|
@@ -115,13 +140,15 @@ export function useTimelineSelectionPreviewSync({
|
|
|
115
140
|
return () => {
|
|
116
141
|
cancelled = true;
|
|
117
142
|
};
|
|
143
|
+
// DOM selection changes are read through refs. Depending on them directly
|
|
144
|
+
// would let the preview-to-timeline echo cancel an in-flight timeline click.
|
|
145
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
118
146
|
}, [
|
|
119
147
|
activeCompPath,
|
|
120
148
|
applyDomSelection,
|
|
121
149
|
applyMarqueeSelection,
|
|
122
150
|
buildDomSelectionForTimelineElement,
|
|
123
|
-
|
|
124
|
-
domEditSelection,
|
|
151
|
+
onSelectionNotFound,
|
|
125
152
|
selectedElementId,
|
|
126
153
|
selectedIds,
|
|
127
154
|
selectedKey,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
const { leaseSpy } = vi.hoisted(() => ({
|
|
7
|
+
leaseSpy: vi.fn((_request: unknown) => ({ status: "loading" as const })),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("../../hooks/useThumbnailLease", () => ({
|
|
11
|
+
useThumbnailLease: leaseSpy,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { AudioWaveform } from "./AudioWaveform";
|
|
15
|
+
|
|
16
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
leaseSpy.mockClear();
|
|
20
|
+
document.body.innerHTML = "";
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("AudioWaveform", () => {
|
|
24
|
+
it("leases waveform decoding with the clip's project, session, and viewport priority", () => {
|
|
25
|
+
const host = document.createElement("div");
|
|
26
|
+
document.body.append(host);
|
|
27
|
+
const root = createRoot(host);
|
|
28
|
+
|
|
29
|
+
act(() => {
|
|
30
|
+
root.render(
|
|
31
|
+
<AudioWaveform
|
|
32
|
+
audioUrl="/media/voice.wav"
|
|
33
|
+
label=""
|
|
34
|
+
labelColor="#fff"
|
|
35
|
+
projectId="project-a"
|
|
36
|
+
sessionEpoch={9}
|
|
37
|
+
priority="interaction"
|
|
38
|
+
/>,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(leaseSpy).toHaveBeenCalled();
|
|
43
|
+
expect(leaseSpy.mock.calls.at(-1)?.[0]).toMatchObject({
|
|
44
|
+
projectId: "project-a",
|
|
45
|
+
sessionEpoch: 9,
|
|
46
|
+
kind: "waveform",
|
|
47
|
+
priority: "interaction",
|
|
48
|
+
rich: false,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
act(() => root.unmount());
|
|
52
|
+
});
|
|
53
|
+
});
|