@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.
Files changed (37) hide show
  1. package/dist/assets/{hyperframes-player-BMHU9-3w.js → hyperframes-player-FmXtbvEE.js} +1 -1
  2. package/dist/assets/{index-DS0kLbuM.css → index-BnsYeuxA.css} +1 -1
  3. package/dist/assets/{index-Ce1SRT2i.js → index-CISQydDK.js} +1 -1
  4. package/dist/assets/{index-DLYk_vu_.js → index-CjueZL58.js} +149 -149
  5. package/dist/assets/{index-Dg0Vjj_i.js → index-DCqmNhmi.js} +1 -1
  6. package/dist/index.d.ts +44 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4941 -4995
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/clipContentExports.test.tsx +54 -0
  13. package/src/components/nle/TimelinePane.tsx +8 -185
  14. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +0 -49
  15. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +4 -15
  16. package/src/components/ui/VideoFrameThumbnail.test.tsx +170 -0
  17. package/src/components/ui/VideoFrameThumbnail.tsx +10 -5
  18. package/src/hooks/useRenderClipContent.ts +1 -1
  19. package/src/index.ts +10 -0
  20. package/src/player/components/AudioWaveform.tsx +1 -1
  21. package/src/player/components/CompositionThumbnail.test.ts +7 -0
  22. package/src/player/components/CompositionThumbnail.tsx +7 -3
  23. package/src/player/components/ImageThumbnail.tsx +1 -1
  24. package/src/player/components/TimelineClipJoins.test.ts +27 -0
  25. package/src/player/components/TimelineClipJoins.tsx +52 -0
  26. package/src/player/components/TimelineLanes.test.tsx +35 -0
  27. package/src/player/components/TimelineLanes.tsx +4 -5
  28. package/src/player/components/TimelineTransitionOverlays.tsx +36 -13
  29. package/src/player/components/timelineDragLanding.ts +1 -5
  30. package/src/player/components/timelineGaps.test.ts +3 -33
  31. package/src/player/components/timelineGaps.ts +4 -11
  32. package/src/player/components/timelineZones.test.ts +0 -5
  33. package/src/player/components/timelineZones.ts +2 -3
  34. package/src/styles/theme.css +3 -0
  35. package/src/telemetry/events.test.ts +0 -6
  36. package/src/telemetry/events.ts +0 -8
  37. 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.62",
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.62",
55
- "@hyperframes/parsers": "0.8.62",
56
- "@hyperframes/sdk": "0.8.62",
57
- "@hyperframes/player": "0.8.62",
58
- "@hyperframes/studio-server": "0.8.62"
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.62"
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 { useCallback, type ReactNode } from "react";
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 — the
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={handleDeleteElement}
106
+ onDeleteElement={onDeleteElement}
284
107
  onAssetDrop={onAssetDrop}
285
108
  onBlockDrop={onBlockDrop}
286
109
  onCompositionDrop={onCompositionDrop}
287
- onMoveElement={handleMoveElement}
288
- onMoveElements={handleMoveElements}
289
- onResizeElement={handleResizeElement}
290
- onResizeElements={handleResizeElements}
110
+ onMoveElement={onMoveElement}
111
+ onMoveElements={onMoveElements}
112
+ onResizeElement={onResizeElement}
113
+ onResizeElements={onResizeElements}
291
114
  onBlockedEditAttempt={onBlockedEditAttempt}
292
- onSplitElement={handleSplitElement}
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, with expanded
48
- * children rebased to local coords via forwardRebasedTimelineMoveElements) —
49
- * optimistic store update + rollback included, so the timeline reflects the
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: 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
 
@@ -97,7 +97,7 @@ function renderAudioClip(
97
97
  });
98
98
  }
99
99
 
100
- interface UseRenderClipContentOptions {
100
+ export interface UseRenderClipContentOptions {
101
101
  projectIdRef: { current: string | null };
102
102
  compIdToSrc: Map<string, string>;
103
103
  activePreviewUrl: string | null;
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 () => {