@hyperframes/studio 0.8.62 → 0.8.63
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-BMHU9-3w.js → hyperframes-player-FmXtbvEE.js} +1 -1
- package/dist/assets/{index-DS0kLbuM.css → index-BnsYeuxA.css} +1 -1
- package/dist/assets/{index-Ce1SRT2i.js → index-CISQydDK.js} +1 -1
- package/dist/assets/{index-DLYk_vu_.js → index-CjueZL58.js} +149 -149
- package/dist/assets/{index-Dg0Vjj_i.js → index-DCqmNhmi.js} +1 -1
- package/dist/index.d.ts +44 -1
- package/dist/index.html +2 -2
- package/dist/index.js +4941 -4995
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/clipContentExports.test.tsx +54 -0
- package/src/components/nle/TimelinePane.tsx +8 -185
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +0 -49
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +4 -15
- package/src/components/ui/VideoFrameThumbnail.test.tsx +170 -0
- package/src/components/ui/VideoFrameThumbnail.tsx +10 -5
- package/src/hooks/useRenderClipContent.ts +1 -1
- package/src/index.ts +10 -0
- package/src/player/components/AudioWaveform.tsx +1 -1
- package/src/player/components/CompositionThumbnail.test.ts +7 -0
- package/src/player/components/CompositionThumbnail.tsx +7 -3
- package/src/player/components/ImageThumbnail.tsx +1 -1
- package/src/player/components/TimelineClipJoins.test.ts +27 -0
- package/src/player/components/TimelineClipJoins.tsx +52 -0
- package/src/player/components/TimelineLanes.test.tsx +35 -0
- package/src/player/components/TimelineLanes.tsx +4 -5
- package/src/player/components/TimelineTransitionOverlays.tsx +36 -13
- package/src/player/components/timelineDragLanding.ts +1 -5
- package/src/player/components/timelineGaps.test.ts +3 -33
- package/src/player/components/timelineGaps.ts +4 -11
- package/src/player/components/timelineZones.test.ts +0 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/styles/theme.css +3 -0
- package/src/telemetry/events.test.ts +0 -6
- package/src/telemetry/events.ts +0 -8
- package/src/components/nle/TimelinePane.test.ts +0 -63
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.63",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"dockview-react": "8.3.1",
|
|
52
52
|
"gsap": "^3.13.0",
|
|
53
53
|
"mediabunny": "^1.45.3",
|
|
54
|
-
"@hyperframes/core": "0.8.
|
|
55
|
-
"@hyperframes/
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/studio-server": "0.8.
|
|
54
|
+
"@hyperframes/core": "0.8.63",
|
|
55
|
+
"@hyperframes/player": "0.8.63",
|
|
56
|
+
"@hyperframes/parsers": "0.8.63",
|
|
57
|
+
"@hyperframes/sdk": "0.8.63",
|
|
58
|
+
"@hyperframes/studio-server": "0.8.63"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"vite": "^6.4.2",
|
|
72
72
|
"vitest": "^4.1.11",
|
|
73
73
|
"zustand": "^5.0.0",
|
|
74
|
-
"@hyperframes/producer": "0.8.
|
|
74
|
+
"@hyperframes/producer": "0.8.63"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"react": "19",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Imports the clip-content thumbnail primitives the way a host app does: by package name.
|
|
3
|
+
// Desktop's timeline lane draws audio waveforms and image thumbnails without a deep import.
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
|
|
7
|
+
|
|
8
|
+
const { leaseSpy } = vi.hoisted(() => ({
|
|
9
|
+
leaseSpy: vi.fn((_request: unknown) => ({ status: "loading" as const })),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("./hooks/useThumbnailLease", () => ({
|
|
13
|
+
useThumbnailLease: leaseSpy,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { AudioWaveform, ImageThumbnail, useRenderClipContent } from "@hyperframes/studio";
|
|
17
|
+
|
|
18
|
+
installReactActEnvironment();
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
leaseSpy.mockClear();
|
|
22
|
+
document.body.innerHTML = "";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("clip content package exports", () => {
|
|
26
|
+
it("mounts AudioWaveform and leases waveform decoding", async () => {
|
|
27
|
+
const root = mountReactHarness(
|
|
28
|
+
<AudioWaveform
|
|
29
|
+
audioUrl="/media/voice.wav"
|
|
30
|
+
label=""
|
|
31
|
+
labelColor="#fff"
|
|
32
|
+
projectId="project-a"
|
|
33
|
+
sessionEpoch={9}
|
|
34
|
+
priority="interaction"
|
|
35
|
+
/>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(leaseSpy).toHaveBeenCalledWith(expect.objectContaining({ kind: "waveform" }));
|
|
39
|
+
await act(async () => root.unmount());
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("mounts ImageThumbnail and leases the still frame", async () => {
|
|
43
|
+
const root = mountReactHarness(
|
|
44
|
+
<ImageThumbnail imageSrc="/media/frame.png" label="" labelColor="#fff" />,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(leaseSpy).toHaveBeenCalledWith(expect.objectContaining({ kind: "image" }));
|
|
48
|
+
await act(async () => root.unmount());
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("exposes useRenderClipContent as a hook a host can call", () => {
|
|
52
|
+
expect(typeof useRenderClipContent).toBe("function");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -1,77 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
import { Timeline } from "../../player";
|
|
3
3
|
import type { TimelineElement, TimelineTimeRange } from "../../player";
|
|
4
4
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
5
5
|
import { AudioMeterStrip } from "./AudioMeterStrip";
|
|
6
6
|
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
7
|
-
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
|
|
8
7
|
import { useNLEContext } from "./NLEContext";
|
|
9
|
-
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
|
10
|
-
|
|
11
|
-
type TimelineMoveEdit = {
|
|
12
|
-
element: TimelineElement;
|
|
13
|
-
updates: Pick<TimelineElement, "start" | "track">;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export function forwardRebasedTimelineMoveElements(
|
|
17
|
-
edits: TimelineMoveEdit[],
|
|
18
|
-
coalesceKey: string | undefined,
|
|
19
|
-
operation: TimelineMoveOperation | undefined,
|
|
20
|
-
onMoveElements: (
|
|
21
|
-
edits: TimelineMoveEdit[],
|
|
22
|
-
coalesceKey?: string,
|
|
23
|
-
operation?: TimelineMoveOperation,
|
|
24
|
-
coalesceMs?: number,
|
|
25
|
-
) => Promise<void> | void,
|
|
26
|
-
coalesceMs?: number,
|
|
27
|
-
) {
|
|
28
|
-
return onMoveElements(
|
|
29
|
-
edits.map(({ element, updates }) => {
|
|
30
|
-
const basis = element.expandedParentStart;
|
|
31
|
-
if (basis === undefined) return { element, updates };
|
|
32
|
-
return {
|
|
33
|
-
element: { ...element, id: element.domId ?? element.id, start: element.start - basis },
|
|
34
|
-
updates: { ...updates, start: Math.max(0, updates.start - basis) },
|
|
35
|
-
};
|
|
36
|
-
}),
|
|
37
|
-
coalesceKey,
|
|
38
|
-
operation,
|
|
39
|
-
coalesceMs,
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
type TimelineResizeChange = {
|
|
44
|
-
element: TimelineElement;
|
|
45
|
-
start: number;
|
|
46
|
-
duration: number;
|
|
47
|
-
playbackStart?: number;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export function forwardRebasedTimelineResizeElements(
|
|
51
|
-
changes: TimelineResizeChange[],
|
|
52
|
-
options: { coalesceKey?: string } | undefined,
|
|
53
|
-
onResizeElements: (
|
|
54
|
-
changes: TimelineResizeChange[],
|
|
55
|
-
options?: { coalesceKey?: string },
|
|
56
|
-
) => Promise<void> | void,
|
|
57
|
-
) {
|
|
58
|
-
return onResizeElements(
|
|
59
|
-
changes.map((change) => {
|
|
60
|
-
const basis = change.element.expandedParentStart;
|
|
61
|
-
if (basis === undefined) return change;
|
|
62
|
-
return {
|
|
63
|
-
...change,
|
|
64
|
-
element: {
|
|
65
|
-
...change.element,
|
|
66
|
-
id: change.element.domId ?? change.element.id,
|
|
67
|
-
start: change.element.start - basis,
|
|
68
|
-
},
|
|
69
|
-
start: Math.max(0, change.start - basis),
|
|
70
|
-
};
|
|
71
|
-
}),
|
|
72
|
-
options,
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
8
|
|
|
76
9
|
export interface TimelinePaneProps {
|
|
77
10
|
/** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */
|
|
@@ -114,7 +47,6 @@ export interface TimelinePaneProps {
|
|
|
114
47
|
canPasteClip?: () => boolean;
|
|
115
48
|
}
|
|
116
49
|
|
|
117
|
-
// fallow-ignore-next-line complexity
|
|
118
50
|
export function TimelinePane({
|
|
119
51
|
timelineToolbar,
|
|
120
52
|
timelineFooter,
|
|
@@ -142,119 +74,10 @@ export function TimelinePane({
|
|
|
142
74
|
timelineSessionEpoch,
|
|
143
75
|
} = useNLEContext();
|
|
144
76
|
|
|
145
|
-
// Move/resize/split come from the timeline edit context, not props
|
|
146
|
-
// wrappers below intercept expanded clips and must call the *real* handlers.
|
|
147
|
-
// (Delete is a direct prop; it stays that way.)
|
|
77
|
+
// Move/resize/split come from the timeline edit context, not props.
|
|
148
78
|
const { onMoveElement, onMoveElements, onResizeElement, onResizeElements, onSplitElement } =
|
|
149
79
|
useTimelineEditContext();
|
|
150
80
|
|
|
151
|
-
// An expanded sub-comp child reaches the normal edit handlers in its own
|
|
152
|
-
// local coordinates: addressed by its real DOM id, with timeline time rebased
|
|
153
|
-
// onto the sub-comp it lives in. The handlers then save + reloadPreview exactly
|
|
154
|
-
// as they do for top-level clips — no separate live-DOM path.
|
|
155
|
-
const toLocalElement = useCallback(
|
|
156
|
-
(element: TimelineElement, basis: number): TimelineElement => ({
|
|
157
|
-
...element,
|
|
158
|
-
id: element.domId ?? element.id,
|
|
159
|
-
start: element.start - basis,
|
|
160
|
-
}),
|
|
161
|
-
[],
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
const handleMoveElement = useCallback(
|
|
165
|
-
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
166
|
-
const basis = element.expandedParentStart;
|
|
167
|
-
if (basis === undefined) return onMoveElement?.(element, updates);
|
|
168
|
-
trackStudioExpandedClipEdit({ action: "move" });
|
|
169
|
-
onMoveElement?.(toLocalElement(element, basis), {
|
|
170
|
-
...updates,
|
|
171
|
-
start: Math.max(0, updates.start - basis),
|
|
172
|
-
});
|
|
173
|
-
},
|
|
174
|
-
[onMoveElement, toLocalElement],
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
// Batched move (ripple / insert): rebase each expanded sub-comp child to its
|
|
178
|
-
// local coords, exactly as handleMoveElement does for a single clip.
|
|
179
|
-
const handleMoveElements = useCallback(
|
|
180
|
-
(
|
|
181
|
-
edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
|
|
182
|
-
coalesceKey?: string,
|
|
183
|
-
operation?: TimelineMoveOperation,
|
|
184
|
-
coalesceMs?: number,
|
|
185
|
-
) => {
|
|
186
|
-
// Match the sibling handlers: report the telemetry when the batch touches at
|
|
187
|
-
// least one expanded sub-comp child (the clips being rebased to local coords).
|
|
188
|
-
if (edits.some(({ element }) => element.expandedParentStart !== undefined)) {
|
|
189
|
-
trackStudioExpandedClipEdit({ action: "move" });
|
|
190
|
-
}
|
|
191
|
-
if (!onMoveElements) return;
|
|
192
|
-
return forwardRebasedTimelineMoveElements(
|
|
193
|
-
edits,
|
|
194
|
-
coalesceKey,
|
|
195
|
-
operation,
|
|
196
|
-
onMoveElements,
|
|
197
|
-
coalesceMs,
|
|
198
|
-
);
|
|
199
|
-
},
|
|
200
|
-
[onMoveElements],
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
const handleResizeElement = useCallback(
|
|
204
|
-
(
|
|
205
|
-
element: TimelineElement,
|
|
206
|
-
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
207
|
-
) => {
|
|
208
|
-
const basis = element.expandedParentStart;
|
|
209
|
-
if (basis === undefined) return onResizeElement?.(element, updates);
|
|
210
|
-
trackStudioExpandedClipEdit({ action: "resize" });
|
|
211
|
-
onResizeElement?.(toLocalElement(element, basis), {
|
|
212
|
-
...updates,
|
|
213
|
-
start: Math.max(0, updates.start - basis),
|
|
214
|
-
});
|
|
215
|
-
},
|
|
216
|
-
[onResizeElement, toLocalElement],
|
|
217
|
-
);
|
|
218
|
-
|
|
219
|
-
const handleResizeElements = useCallback(
|
|
220
|
-
(
|
|
221
|
-
changes: Array<{
|
|
222
|
-
element: TimelineElement;
|
|
223
|
-
start: number;
|
|
224
|
-
duration: number;
|
|
225
|
-
playbackStart?: number;
|
|
226
|
-
}>,
|
|
227
|
-
options?: { coalesceKey?: string },
|
|
228
|
-
) => {
|
|
229
|
-
if (!onResizeElements) return;
|
|
230
|
-
if (changes.some(({ element }) => element.expandedParentStart !== undefined)) {
|
|
231
|
-
trackStudioExpandedClipEdit({ action: "resize" });
|
|
232
|
-
}
|
|
233
|
-
return forwardRebasedTimelineResizeElements(changes, options, onResizeElements);
|
|
234
|
-
},
|
|
235
|
-
[onResizeElements],
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
const handleDeleteElement = useCallback(
|
|
239
|
-
(element: TimelineElement) => {
|
|
240
|
-
const basis = element.expandedParentStart;
|
|
241
|
-
if (basis === undefined) return onDeleteElement?.(element);
|
|
242
|
-
trackStudioExpandedClipEdit({ action: "delete" });
|
|
243
|
-
return onDeleteElement?.(toLocalElement(element, basis));
|
|
244
|
-
},
|
|
245
|
-
[onDeleteElement, toLocalElement],
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
const handleSplitElement = useCallback(
|
|
249
|
-
(element: TimelineElement, splitTime: number) => {
|
|
250
|
-
const basis = element.expandedParentStart;
|
|
251
|
-
if (basis === undefined) return onSplitElement?.(element, splitTime);
|
|
252
|
-
trackStudioExpandedClipEdit({ action: "split" });
|
|
253
|
-
return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis));
|
|
254
|
-
},
|
|
255
|
-
[onSplitElement, toLocalElement],
|
|
256
|
-
);
|
|
257
|
-
|
|
258
81
|
return (
|
|
259
82
|
<div
|
|
260
83
|
className="relative flex h-full flex-col"
|
|
@@ -280,16 +103,16 @@ export function TimelinePane({
|
|
|
280
103
|
onDrillDown={handleDrillDown}
|
|
281
104
|
renderClipContent={renderClipContent}
|
|
282
105
|
onFileDrop={onFileDrop}
|
|
283
|
-
onDeleteElement={
|
|
106
|
+
onDeleteElement={onDeleteElement}
|
|
284
107
|
onAssetDrop={onAssetDrop}
|
|
285
108
|
onBlockDrop={onBlockDrop}
|
|
286
109
|
onCompositionDrop={onCompositionDrop}
|
|
287
|
-
onMoveElement={
|
|
288
|
-
onMoveElements={
|
|
289
|
-
onResizeElement={
|
|
290
|
-
onResizeElements={
|
|
110
|
+
onMoveElement={onMoveElement}
|
|
111
|
+
onMoveElements={onMoveElements}
|
|
112
|
+
onResizeElement={onResizeElement}
|
|
113
|
+
onResizeElements={onResizeElements}
|
|
291
114
|
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
292
|
-
onSplitElement={
|
|
115
|
+
onSplitElement={onSplitElement}
|
|
293
116
|
onSelectElement={onSelectTimelineElement}
|
|
294
117
|
onRangeSelect={onRangeSelect}
|
|
295
118
|
onCopyClip={onCopyClip}
|
|
@@ -321,55 +321,6 @@ describe("useCanvasZOrderTimelineMirror", () => {
|
|
|
321
321
|
expect(history.moveCoalesceKeys).toEqual([]);
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
-
it("maps the crossed neighbor to its timeline key and rebases expanded sub-comp children", async () => {
|
|
325
|
-
// t is an expanded sub-comp child (expandedParentStart 5, absolute start 5):
|
|
326
|
-
// the mirror must forward its persist in LOCAL time (start 0), the same
|
|
327
|
-
// rebase a timeline lane drag applies (forwardRebasedTimelineMoveElements).
|
|
328
|
-
setStoreElements([
|
|
329
|
-
{
|
|
330
|
-
...storeEl("a", 0, 25, 5),
|
|
331
|
-
sourceFile: "sub.html",
|
|
332
|
-
key: "sub.html#a",
|
|
333
|
-
expandedParentStart: 5,
|
|
334
|
-
},
|
|
335
|
-
{
|
|
336
|
-
...storeEl("b", 1, 5, 10),
|
|
337
|
-
sourceFile: "sub.html",
|
|
338
|
-
key: "sub.html#b",
|
|
339
|
-
expandedParentStart: 5,
|
|
340
|
-
},
|
|
341
|
-
{
|
|
342
|
-
...storeEl("t", 2, 5, 10),
|
|
343
|
-
sourceFile: "sub.html",
|
|
344
|
-
key: "sub.html#t",
|
|
345
|
-
expandedParentStart: 5,
|
|
346
|
-
},
|
|
347
|
-
]);
|
|
348
|
-
const edits: Array<{ element: TimelineElement; updates: { start: number; track: number } }> =
|
|
349
|
-
[];
|
|
350
|
-
const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (batch) => {
|
|
351
|
-
edits.push(...batch);
|
|
352
|
-
};
|
|
353
|
-
const { mirror } = mountMirrorOnlyHarness(onMoveElements);
|
|
354
|
-
|
|
355
|
-
const mirrored = await act(async () =>
|
|
356
|
-
mirror({
|
|
357
|
-
selectionKey: "sub.html#t",
|
|
358
|
-
action: "bring-forward",
|
|
359
|
-
// The crossed sibling maps to sub.html#b via its DOM id + sourceFile —
|
|
360
|
-
// the same derivation reorder entries use (deriveTimelineStoreKey).
|
|
361
|
-
crossed: domTarget("b"),
|
|
362
|
-
sourceFile: "sub.html",
|
|
363
|
-
coalesceKey: "z-reorder:bring-forward:t",
|
|
364
|
-
}),
|
|
365
|
-
);
|
|
366
|
-
expect(mirrored).toBe(true);
|
|
367
|
-
expect(edits).toHaveLength(1);
|
|
368
|
-
// Rebased to sub-comp local coords: absolute 5 − parent start 5 = 0.
|
|
369
|
-
expect(edits[0].element.start).toBe(0);
|
|
370
|
-
expect(edits[0].updates).toMatchObject({ start: 0, track: 0 });
|
|
371
|
-
});
|
|
372
|
-
|
|
373
324
|
it("maps a cross-file duplicate selector to the source-scoped crossed occurrence", async () => {
|
|
374
325
|
setStoreElements([
|
|
375
326
|
{
|
|
@@ -13,7 +13,6 @@ import { commitZMirrorLaneMove } from "../../player/components/timelineClipDragC
|
|
|
13
13
|
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
|
14
14
|
import { buildStableSelector, getSelectorIndex } from "../editor/domEditingDom";
|
|
15
15
|
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
16
|
-
import { forwardRebasedTimelineMoveElements } from "./TimelinePane";
|
|
17
16
|
|
|
18
17
|
export interface MirrorZOrderInput {
|
|
19
18
|
/** Timeline store key of the element the menu acted on (entry.key), if any. */
|
|
@@ -44,10 +43,9 @@ export interface MirrorZOrderInput {
|
|
|
44
43
|
* renders and the resolver expects. No alternate row expansion is built here.
|
|
45
44
|
*
|
|
46
45
|
* The mirror persists through the SAME machinery as a timeline lane drag
|
|
47
|
-
* (commitZMirrorLaneMove → persistMoveEdits → onMoveElements
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* lane change without a reload. The deps below deliberately OMIT
|
|
46
|
+
* (commitZMirrorLaneMove → persistMoveEdits → onMoveElements) — optimistic
|
|
47
|
+
* store update + rollback included, so the timeline reflects the lane change
|
|
48
|
+
* without a reload. The deps below deliberately OMIT
|
|
51
49
|
* `readZIndex`/`onStackingPatches`: the lane→z stacking sync
|
|
52
50
|
* (syncStackingForEdit) must not fire and recompute the z values the user just
|
|
53
51
|
* set — commitZMirrorLaneMove never calls it, and without these deps it would
|
|
@@ -160,16 +158,7 @@ function useMirrorLaneMoveCommit(): (
|
|
|
160
158
|
elements: els,
|
|
161
159
|
trackOrder: displayTrackOrder(els),
|
|
162
160
|
updateElement: (key, updates) => usePlayerStore.getState().updateElement(key, updates),
|
|
163
|
-
onMoveElements
|
|
164
|
-
? (edits, coalesceKey2, operation, coalesceMs) =>
|
|
165
|
-
forwardRebasedTimelineMoveElements(
|
|
166
|
-
edits,
|
|
167
|
-
coalesceKey2,
|
|
168
|
-
operation,
|
|
169
|
-
onMoveElements,
|
|
170
|
-
coalesceMs,
|
|
171
|
-
)
|
|
172
|
-
: undefined,
|
|
161
|
+
onMoveElements,
|
|
173
162
|
// NO readZIndex / onStackingPatches: see the hook doc — the lane→z
|
|
174
163
|
// stacking sync must not re-trigger and fight the just-set z values.
|
|
175
164
|
},
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { VideoFrameThumbnail } from "./VideoFrameThumbnail";
|
|
6
|
+
|
|
7
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
8
|
+
|
|
9
|
+
// The component builds its thumbnail from document.createElement("video"/"canvas"),
|
|
10
|
+
// so both are faked here. The video fake records load() calls and keeps listener
|
|
11
|
+
// sets, letting a test re-fire an event handler after cleanup removed it — the
|
|
12
|
+
// exact shape of the error→cleanup→error loop this suite guards against.
|
|
13
|
+
interface FakeVideo {
|
|
14
|
+
crossOrigin: string;
|
|
15
|
+
muted: boolean;
|
|
16
|
+
preload: string;
|
|
17
|
+
duration: number;
|
|
18
|
+
videoWidth: number;
|
|
19
|
+
videoHeight: number;
|
|
20
|
+
currentTime: number;
|
|
21
|
+
loadCalls: number;
|
|
22
|
+
_src: string;
|
|
23
|
+
src: string;
|
|
24
|
+
addEventListener(type: string, fn: () => void): void;
|
|
25
|
+
removeEventListener(type: string, fn: () => void): void;
|
|
26
|
+
getAttribute(name: string): string | null;
|
|
27
|
+
load(): void;
|
|
28
|
+
dispatch(type: string): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeVideo(): FakeVideo {
|
|
32
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
33
|
+
const video: FakeVideo = {
|
|
34
|
+
crossOrigin: "",
|
|
35
|
+
muted: false,
|
|
36
|
+
preload: "",
|
|
37
|
+
duration: 10,
|
|
38
|
+
videoWidth: 640,
|
|
39
|
+
videoHeight: 360,
|
|
40
|
+
currentTime: 0,
|
|
41
|
+
loadCalls: 0,
|
|
42
|
+
_src: "",
|
|
43
|
+
src: "",
|
|
44
|
+
addEventListener(type, fn) {
|
|
45
|
+
if (!listeners.has(type)) listeners.set(type, new Set());
|
|
46
|
+
listeners.get(type)!.add(fn);
|
|
47
|
+
},
|
|
48
|
+
removeEventListener(type, fn) {
|
|
49
|
+
listeners.get(type)?.delete(fn);
|
|
50
|
+
},
|
|
51
|
+
getAttribute(name) {
|
|
52
|
+
return name === "src" ? video.src : null;
|
|
53
|
+
},
|
|
54
|
+
load() {
|
|
55
|
+
video.loadCalls++;
|
|
56
|
+
},
|
|
57
|
+
dispatch(type) {
|
|
58
|
+
for (const fn of [...(listeners.get(type) ?? [])]) fn();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
Object.defineProperty(video, "src", {
|
|
62
|
+
get: () => video._src,
|
|
63
|
+
set: (v: string) => {
|
|
64
|
+
video._src = v;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
return video;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const canvas = {
|
|
71
|
+
width: 0,
|
|
72
|
+
height: 0,
|
|
73
|
+
getContext: () => ({ drawImage: () => {} }),
|
|
74
|
+
toDataURL: () => "data:image/jpeg;base64,AAAA",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
describe("VideoFrameThumbnail", () => {
|
|
78
|
+
let videos: FakeVideo[];
|
|
79
|
+
let root: Root;
|
|
80
|
+
let container: HTMLDivElement;
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
videos = [];
|
|
84
|
+
const original = document.createElement.bind(document);
|
|
85
|
+
vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
|
|
86
|
+
if (tag === "video") {
|
|
87
|
+
const v = makeVideo();
|
|
88
|
+
videos.push(v);
|
|
89
|
+
return v as unknown as HTMLVideoElement;
|
|
90
|
+
}
|
|
91
|
+
if (tag === "canvas") return canvas as unknown as HTMLCanvasElement;
|
|
92
|
+
return original(tag);
|
|
93
|
+
}) as typeof document.createElement);
|
|
94
|
+
container = document.createElement("div");
|
|
95
|
+
document.body.append(container);
|
|
96
|
+
root = createRoot(container);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
act(() => root.unmount());
|
|
101
|
+
container.remove();
|
|
102
|
+
vi.restoreAllMocks();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const render = (props: { src: string; fallbackLabel?: string }) => {
|
|
106
|
+
act(() => root.render(<VideoFrameThumbnail {...props} />));
|
|
107
|
+
return videos[videos.length - 1];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
it("renders the fallback label when the video errors", () => {
|
|
111
|
+
render({ src: "missing.mp4", fallbackLabel: "VIDEO" });
|
|
112
|
+
const video = videos[0];
|
|
113
|
+
expect(video.src).toBe("missing.mp4");
|
|
114
|
+
expect(video.loadCalls).toBe(1);
|
|
115
|
+
|
|
116
|
+
act(() => video.dispatch("error"));
|
|
117
|
+
|
|
118
|
+
expect(container.textContent).toContain("VIDEO");
|
|
119
|
+
// cleanup ran once: detached the error listener and reset the media element
|
|
120
|
+
expect(video.src).toBe("");
|
|
121
|
+
expect(video.loadCalls).toBe(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("does not loop when the cleared src fires a synthetic error", () => {
|
|
125
|
+
render({ src: "missing.mp4", fallbackLabel: "VIDEO" });
|
|
126
|
+
const video = videos[0];
|
|
127
|
+
act(() => video.dispatch("error"));
|
|
128
|
+
expect(video.loadCalls).toBe(2);
|
|
129
|
+
|
|
130
|
+
// The empty src makes the browser fire `error` again; the detached handler
|
|
131
|
+
// must stay detached — repeated dispatches must not touch load() anymore.
|
|
132
|
+
act(() => {
|
|
133
|
+
video.dispatch("error");
|
|
134
|
+
video.dispatch("error");
|
|
135
|
+
video.dispatch("error");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(video.loadCalls).toBe(2);
|
|
139
|
+
expect(container.textContent).toContain("VIDEO");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("keeps the extracted frame and stays inert after a post-seek synthetic error", () => {
|
|
143
|
+
render({ src: "clip.mp4" });
|
|
144
|
+
const video = videos[0];
|
|
145
|
+
|
|
146
|
+
act(() => video.dispatch("loadedmetadata"));
|
|
147
|
+
expect(video.currentTime).toBe(1); // 10% of a 10s clip
|
|
148
|
+
|
|
149
|
+
act(() => video.dispatch("seeked"));
|
|
150
|
+
const img = container.querySelector("img");
|
|
151
|
+
expect(img?.getAttribute("src")).toBe("data:image/jpeg;base64,AAAA");
|
|
152
|
+
expect(video.src).toBe("");
|
|
153
|
+
expect(video.loadCalls).toBe(2);
|
|
154
|
+
|
|
155
|
+
act(() => {
|
|
156
|
+
video.dispatch("error");
|
|
157
|
+
video.dispatch("error");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(container.querySelector("img")?.getAttribute("src")).toBe("data:image/jpeg;base64,AAAA");
|
|
161
|
+
expect(video.loadCalls).toBe(2);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("retries with a fresh video element when src changes", () => {
|
|
165
|
+
render({ src: "a.mp4" });
|
|
166
|
+
act(() => root.render(<VideoFrameThumbnail src="b.mp4" />));
|
|
167
|
+
expect(videos.length).toBe(2);
|
|
168
|
+
expect(videos[1].src).toBe("b.mp4");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -26,10 +26,19 @@ export function VideoFrameThumbnail({
|
|
|
26
26
|
const canvas = document.createElement("canvas");
|
|
27
27
|
const ctx = canvas.getContext("2d");
|
|
28
28
|
|
|
29
|
+
// Clearing src fires one more `error` on the video, so the error handler
|
|
30
|
+
// must be detached first — otherwise error → cleanup → error spins forever.
|
|
29
31
|
const cleanup = () => {
|
|
32
|
+
video.removeEventListener("error", onError);
|
|
30
33
|
video.src = "";
|
|
31
34
|
video.load();
|
|
32
35
|
};
|
|
36
|
+
const onError = () => {
|
|
37
|
+
// Ignore the synthetic error cleanup itself just triggered.
|
|
38
|
+
if (!video.getAttribute("src")) return;
|
|
39
|
+
setFailed(true);
|
|
40
|
+
cleanup();
|
|
41
|
+
};
|
|
33
42
|
|
|
34
43
|
video.addEventListener("loadedmetadata", () => {
|
|
35
44
|
video.currentTime = Math.min(2, video.duration * 0.1 || 2);
|
|
@@ -44,11 +53,7 @@ export function VideoFrameThumbnail({
|
|
|
44
53
|
cleanup();
|
|
45
54
|
});
|
|
46
55
|
|
|
47
|
-
video.addEventListener("error",
|
|
48
|
-
// Resolve the loading state — a permanent shimmer reads as "still loading".
|
|
49
|
-
setFailed(true);
|
|
50
|
-
cleanup();
|
|
51
|
-
});
|
|
56
|
+
video.addEventListener("error", onError);
|
|
52
57
|
video.src = src;
|
|
53
58
|
video.load();
|
|
54
59
|
|
package/src/index.ts
CHANGED
|
@@ -77,6 +77,16 @@ export {
|
|
|
77
77
|
export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
|
|
78
78
|
export type { TimelineTheme } from "./player/components/timelineTheme";
|
|
79
79
|
|
|
80
|
+
// Clip content thumbnails: used by a host rendering its own timeline lane.
|
|
81
|
+
export { AudioWaveform } from "./player/components/AudioWaveform";
|
|
82
|
+
export type { AudioWaveformProps } from "./player/components/AudioWaveform";
|
|
83
|
+
export { ImageThumbnail } from "./player/components/ImageThumbnail";
|
|
84
|
+
export type { ImageThumbnailProps } from "./player/components/ImageThumbnail";
|
|
85
|
+
export { useRenderClipContent } from "./hooks/useRenderClipContent";
|
|
86
|
+
export type { UseRenderClipContentOptions } from "./hooks/useRenderClipContent";
|
|
87
|
+
export type { ThumbnailPriority } from "./player/lib/thumbnailScheduler";
|
|
88
|
+
export type { TimelineClipRenderContext } from "./player/components/TimelineTypes";
|
|
89
|
+
|
|
80
90
|
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
|
|
81
91
|
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
82
92
|
export type { PreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
@@ -4,7 +4,7 @@ import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
|
4
4
|
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
5
5
|
import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
|
|
6
6
|
|
|
7
|
-
interface AudioWaveformProps {
|
|
7
|
+
export interface AudioWaveformProps {
|
|
8
8
|
audioUrl: string;
|
|
9
9
|
waveformUrl?: string;
|
|
10
10
|
label: string;
|
|
@@ -137,6 +137,13 @@ describe("CompositionThumbnail", () => {
|
|
|
137
137
|
const tiles = [...host.querySelectorAll("img")];
|
|
138
138
|
expect(tiles.length).toBeGreaterThan(0);
|
|
139
139
|
expect(tiles.every((tile) => !tile.classList.contains("hidden"))).toBe(true);
|
|
140
|
+
// Pictures read untinted by default, like video filmstrips; the theme tokens own any dimming.
|
|
141
|
+
expect(
|
|
142
|
+
tiles.every((tile) => tile.style.opacity === "var(--timeline-composition-thumbnail-opacity)"),
|
|
143
|
+
).toBe(true);
|
|
144
|
+
expect(tiles[0]?.parentElement?.parentElement?.style.mixBlendMode).toBe(
|
|
145
|
+
"var(--timeline-composition-thumbnail-blend)",
|
|
146
|
+
);
|
|
140
147
|
});
|
|
141
148
|
|
|
142
149
|
it("aborts its scheduled off-DOM image probe when unmounted", async () => {
|