@hyperframes/studio 0.8.57 → 0.8.58
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-c23wnIfc.js → hyperframes-player-zGyOcwwh.js} +1 -1
- package/dist/assets/{index-DgkAzx3n.js → index-BcE2OSVA.js} +1 -1
- package/dist/assets/{index-IDNr3X1H.js → index-ZJaRCDYV.js} +1 -1
- package/dist/assets/{index-gbOCDCuZ.js → index-d8ferjRc.js} +123 -123
- package/dist/index.d.ts +30 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1217 -1151
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/MotionPathOverlay.test.tsx +0 -1
- package/src/components/nle/NLEContext.tsx +8 -2
- package/src/components/nle/NLEPreview.test.ts +1 -0
- package/src/components/nle/useCompositionStack.ts +1 -1
- package/src/hooks/useCompositionDimensions.test.tsx +0 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +28 -0
- package/src/hooks/useSdkSession.ts +31 -4
- package/src/index.ts +9 -1
- package/src/player/components/LayerDisclosureRow.tsx +3 -3
- package/src/player/components/Player.test.ts +34 -0
- package/src/player/components/TimelineGroupHeader.tsx +4 -6
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
- package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
- package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
- package/src/player/hooks/usePlayerHandle.ts +94 -0
- package/src/player/index.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.58",
|
|
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/sdk": "0.8.
|
|
58
|
-
"@hyperframes/
|
|
54
|
+
"@hyperframes/core": "0.8.58",
|
|
55
|
+
"@hyperframes/parsers": "0.8.58",
|
|
56
|
+
"@hyperframes/studio-server": "0.8.58",
|
|
57
|
+
"@hyperframes/sdk": "0.8.58",
|
|
58
|
+
"@hyperframes/player": "0.8.58"
|
|
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.58"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"react": "19",
|
|
@@ -52,7 +52,6 @@ it("re-evaluates set-destination availability when a reload swaps the live ifram
|
|
|
52
52
|
expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(false);
|
|
53
53
|
|
|
54
54
|
act(() => {
|
|
55
|
-
ref.current = after;
|
|
56
55
|
usePreviewIframeStore.getState().setIframe(after);
|
|
57
56
|
});
|
|
58
57
|
expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(true);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
|
|
3
|
-
import { useTimelinePlayer
|
|
4
|
-
import type
|
|
3
|
+
import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer";
|
|
4
|
+
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
|
|
5
5
|
import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
|
|
6
6
|
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
|
7
7
|
import { useCompositionStack } from "./useCompositionStack";
|
|
@@ -18,6 +18,8 @@ export interface NLEContextValue {
|
|
|
18
18
|
projectId: string;
|
|
19
19
|
// player (from useTimelinePlayer — single instance for the whole shell)
|
|
20
20
|
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
21
|
+
play: () => void;
|
|
22
|
+
pause: () => void;
|
|
21
23
|
togglePlay: () => void;
|
|
22
24
|
seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
|
|
23
25
|
refreshPlayer: () => void;
|
|
@@ -84,6 +86,8 @@ export function NLEProvider({
|
|
|
84
86
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
85
87
|
const {
|
|
86
88
|
iframeRef,
|
|
89
|
+
play,
|
|
90
|
+
pause,
|
|
87
91
|
togglePlay,
|
|
88
92
|
seek,
|
|
89
93
|
onIframeLoad: baseOnIframeLoad,
|
|
@@ -298,6 +302,8 @@ export function NLEProvider({
|
|
|
298
302
|
const value: NLEContextValue = {
|
|
299
303
|
projectId,
|
|
300
304
|
iframeRef,
|
|
305
|
+
play,
|
|
306
|
+
pause,
|
|
301
307
|
containerRef,
|
|
302
308
|
togglePlay,
|
|
303
309
|
seek,
|
|
@@ -261,6 +261,7 @@ describe("NLEPreview", () => {
|
|
|
261
261
|
expect(players[0].style.clipPath).toBe("");
|
|
262
262
|
expect(players[1].style.clipPath).toBe("inset(100%)");
|
|
263
263
|
expect(players[1].style.visibility).toBe("hidden");
|
|
264
|
+
expect(players[1].style.pointerEvents).toBe("none");
|
|
264
265
|
view.cleanup();
|
|
265
266
|
});
|
|
266
267
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
// Composition drill-down stack management for NLEContext/EditorShell
|
|
3
3
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
4
|
-
import { usePlayerStore } from "../../player";
|
|
4
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
5
5
|
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
|
6
6
|
import { encodePreviewPath } from "../../player/components/thumbnailUtils";
|
|
7
7
|
|
|
@@ -71,7 +71,6 @@ it("re-reads the size from the promoted iframe's document", () => {
|
|
|
71
71
|
act(() => root.render(<Harness />));
|
|
72
72
|
expect(host.textContent).toBe('{"width":1920,"height":1080}');
|
|
73
73
|
act(() => {
|
|
74
|
-
ref.current = b;
|
|
75
74
|
usePreviewIframeStore.getState().setIframe(b);
|
|
76
75
|
});
|
|
77
76
|
expect(host.textContent).toBe('{"width":1080,"height":1920}');
|
|
@@ -267,6 +267,34 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
267
267
|
await act(async () => root.unmount());
|
|
268
268
|
});
|
|
269
269
|
|
|
270
|
+
// A fetch that REJECTS produces no response at all. It used to escape this
|
|
271
|
+
// function and be caught by the effect's outer `.catch`, which reported it as
|
|
272
|
+
// `stage: "open"` — a label that means openComposition threw. Every `stage:
|
|
273
|
+
// open` event on 0.8.56/0.8.57 carries a fetch-rejection message, so the
|
|
274
|
+
// largest failure class was a network problem reported as a parser one and
|
|
275
|
+
// was unaddressable in that bucket.
|
|
276
|
+
it("reports a rejected request as a read failure, not an open failure", async () => {
|
|
277
|
+
vi.stubGlobal(
|
|
278
|
+
"fetch",
|
|
279
|
+
vi.fn(async () => {
|
|
280
|
+
throw new TypeError("Failed to fetch");
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
const root = createRoot(document.createElement("div"));
|
|
284
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
285
|
+
await flushAsyncEffects();
|
|
286
|
+
|
|
287
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
288
|
+
stage: "read",
|
|
289
|
+
reason: "network",
|
|
290
|
+
});
|
|
291
|
+
expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
292
|
+
stage: "open",
|
|
293
|
+
error: expect.anything(),
|
|
294
|
+
});
|
|
295
|
+
await act(async () => root.unmount());
|
|
296
|
+
});
|
|
297
|
+
|
|
270
298
|
it("separates an unexpected response shape from a failed request", async () => {
|
|
271
299
|
vi.stubGlobal(
|
|
272
300
|
"fetch",
|
|
@@ -16,12 +16,23 @@ import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
|
16
16
|
*
|
|
17
17
|
* Every reason lives in this union so the full surface is readable from one
|
|
18
18
|
* place — `absent_or_empty` included, even though it is a 2xx.
|
|
19
|
+
*
|
|
20
|
+
* `network` is a fetch that REJECTED — the request never produced a response.
|
|
21
|
+
* It was not in this union until 2026-09-21, so it escaped `readProjectFileOptional`
|
|
22
|
+
* and was caught by the effect's outer `.catch`, landing in `stage: "open"`.
|
|
23
|
+
* That label means `openComposition` threw (unparseable composition, OOM), and
|
|
24
|
+
* it is what the largest failure class was reported as. Every `stage: open`
|
|
25
|
+
* event measured on 0.8.56 and 0.8.57 carries a fetch-rejection message —
|
|
26
|
+
* "Failed to fetch", "Load failed", "NetworkError when attempting to fetch
|
|
27
|
+
* resource." — so the class was a network problem filed under a parser one, and
|
|
28
|
+
* unaddressable in that bucket.
|
|
19
29
|
*/
|
|
20
30
|
type ProjectFileReadFailure =
|
|
21
31
|
| { ok: false; reason: "unsafe_path" }
|
|
22
32
|
| { ok: false; reason: "http_error"; status: number }
|
|
23
33
|
| { ok: false; reason: "missing_content" }
|
|
24
|
-
| { ok: false; reason: "absent_or_empty" }
|
|
34
|
+
| { ok: false; reason: "absent_or_empty" }
|
|
35
|
+
| { ok: false; reason: "network" };
|
|
25
36
|
|
|
26
37
|
type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
|
|
27
38
|
|
|
@@ -68,9 +79,20 @@ async function readProjectFileOptional(
|
|
|
68
79
|
// and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
|
|
69
80
|
// already confines both values to single segments of this same-origin URL.
|
|
70
81
|
if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
let res: Response;
|
|
83
|
+
try {
|
|
84
|
+
res = await fetch(
|
|
85
|
+
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
86
|
+
);
|
|
87
|
+
} catch {
|
|
88
|
+
// The request never produced a response: offline, the dev server gone, a
|
|
89
|
+
// CSP/Private-Network-Access block, or an extension rewriting fetch. Caught
|
|
90
|
+
// here so it is reported as a READ failure — left to propagate, the effect's
|
|
91
|
+
// outer catch reported it as `stage: "open"`, which claims the composition
|
|
92
|
+
// failed to parse. Every `stage: open` event before this change was this
|
|
93
|
+
// branch, so the label made the largest class unaddressable.
|
|
94
|
+
return { ok: false, reason: "network" };
|
|
95
|
+
}
|
|
74
96
|
if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
|
|
75
97
|
const data = (await res.json()) as { content?: string };
|
|
76
98
|
// `optional=1` answers a missing file with 200 + `content: ""`, so a
|
|
@@ -299,6 +321,11 @@ export function useSdkSession(
|
|
|
299
321
|
// openComposition threw (unparseable composition, OOM) — same total
|
|
300
322
|
// bypass as the read failure above, but this one is a real defect
|
|
301
323
|
// rather than a missing file. Carry the message; it is the only clue.
|
|
324
|
+
//
|
|
325
|
+
// A rejected read no longer reaches here: `readProjectFileOptional`
|
|
326
|
+
// catches its own fetch rejection and answers `reason: "network"`, so
|
|
327
|
+
// this stage now means what it says. Before that, every event in this
|
|
328
|
+
// bucket was a network error wearing a parser's label.
|
|
302
329
|
trackStudioEvent("sdk_session_unavailable", {
|
|
303
330
|
stage: "open",
|
|
304
331
|
error: error instanceof Error ? error.message : String(error),
|
package/src/index.ts
CHANGED
|
@@ -46,12 +46,20 @@ export {
|
|
|
46
46
|
VideoThumbnail,
|
|
47
47
|
CompositionThumbnail,
|
|
48
48
|
useTimelinePlayer,
|
|
49
|
+
usePlayerHandle,
|
|
49
50
|
resolveIframe,
|
|
50
51
|
usePlayerStore,
|
|
51
52
|
liveTime,
|
|
52
53
|
formatTime,
|
|
53
54
|
} from "./player";
|
|
54
|
-
export type {
|
|
55
|
+
export type {
|
|
56
|
+
PlayerHandle,
|
|
57
|
+
PlayerHandleElement,
|
|
58
|
+
PlayerHandleListener,
|
|
59
|
+
PlayerHandleTimeListener,
|
|
60
|
+
TimelineElement,
|
|
61
|
+
TimelineTimeRange,
|
|
62
|
+
} from "./player";
|
|
55
63
|
|
|
56
64
|
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
|
|
57
65
|
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
@@ -95,9 +95,9 @@ export function LayerDisclosureRow({
|
|
|
95
95
|
<span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
|
|
96
96
|
◇
|
|
97
97
|
</span>
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
<span title={name} className="min-w-0 flex-1 truncate font-medium leading-tight">
|
|
99
|
+
{name}
|
|
100
|
+
</span>
|
|
101
101
|
<TrackClipCount clipCount={clipCount} />
|
|
102
102
|
{children}
|
|
103
103
|
{/* Anchored right, on every header that has one: the lane toggle is the
|
|
@@ -85,6 +85,8 @@ async function mountPlayer(props: Partial<PlayerProps> = {}) {
|
|
|
85
85
|
|
|
86
86
|
const twoFrames = () => act(async () => void (await new Promise((r) => setTimeout(r, 80))));
|
|
87
87
|
|
|
88
|
+
const flushEffects = () => act(async () => await Promise.resolve());
|
|
89
|
+
|
|
88
90
|
function createAudioIframe() {
|
|
89
91
|
const iframe = document.createElement("iframe");
|
|
90
92
|
document.body.appendChild(iframe);
|
|
@@ -216,6 +218,38 @@ describe("ready to show", () => {
|
|
|
216
218
|
const painted = (player: TestHyperframesPlayer) =>
|
|
217
219
|
act(() => void player.dispatchEvent(new Event("painted")));
|
|
218
220
|
|
|
221
|
+
it("waits two animation frames before notifying that the preview can show", async () => {
|
|
222
|
+
const callbacks: FrameRequestCallback[] = [];
|
|
223
|
+
const requestAnimationFrame = vi
|
|
224
|
+
.spyOn(window, "requestAnimationFrame")
|
|
225
|
+
.mockImplementation((callback) => {
|
|
226
|
+
callbacks.push(callback);
|
|
227
|
+
return callbacks.length;
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
const onReadyToShowChange = vi.fn();
|
|
231
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
232
|
+
const el = player as TestHyperframesPlayer;
|
|
233
|
+
|
|
234
|
+
loadAndReady(el);
|
|
235
|
+
painted(el);
|
|
236
|
+
await flushEffects();
|
|
237
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
238
|
+
expect(callbacks).toHaveLength(1);
|
|
239
|
+
|
|
240
|
+
act(() => callbacks.shift()?.(0));
|
|
241
|
+
await flushEffects();
|
|
242
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
243
|
+
expect(callbacks).toHaveLength(1);
|
|
244
|
+
|
|
245
|
+
act(() => callbacks.shift()?.(16));
|
|
246
|
+
await flushEffects();
|
|
247
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
|
|
248
|
+
} finally {
|
|
249
|
+
requestAnimationFrame.mockRestore();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
219
253
|
it("promotes only once the player reports painted, not at ready or assetsready", async () => {
|
|
220
254
|
const onReadyToShowChange = vi.fn();
|
|
221
255
|
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
@@ -65,9 +65,9 @@ function GroupNameButton({
|
|
|
65
65
|
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
|
|
66
66
|
▤
|
|
67
67
|
</span>
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
<span title={label} className="min-w-0 truncate text-left font-medium leading-tight">
|
|
69
|
+
{label}
|
|
70
|
+
</span>
|
|
71
71
|
<span
|
|
72
72
|
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
|
|
73
73
|
aria-hidden="true"
|
|
@@ -109,9 +109,7 @@ export function TimelineGroupHeader({
|
|
|
109
109
|
}}
|
|
110
110
|
>
|
|
111
111
|
{/* One line, like a track header's: caret and name, then every control
|
|
112
|
-
anchored to the right edge.
|
|
113
|
-
`shrink-0`, so they hold the edge and the name gives way — no second
|
|
114
|
-
line needed to keep five controls off the label. */}
|
|
112
|
+
anchored to the right edge. */}
|
|
115
113
|
<button
|
|
116
114
|
type="button"
|
|
117
115
|
tabIndex={-1}
|
|
@@ -136,4 +136,14 @@ describe("PlainTrackHeader", () => {
|
|
|
136
136
|
expect(labelOf(view.host)).toBe("Hide track 1");
|
|
137
137
|
view.unmount();
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
it("truncates long names and keeps the full name in the tooltip", () => {
|
|
141
|
+
const name = "A-very-long-unbroken-clip-name-for-the-timeline";
|
|
142
|
+
const view = renderHeader({ trackLabel: name });
|
|
143
|
+
const label = view.host.querySelector("span[title]");
|
|
144
|
+
expect(label?.className).toContain("truncate");
|
|
145
|
+
expect(label?.className).not.toContain("wrap-break-word");
|
|
146
|
+
expect(label?.getAttribute("title")).toBe(name);
|
|
147
|
+
view.unmount();
|
|
148
|
+
});
|
|
139
149
|
});
|
|
@@ -93,14 +93,11 @@ export function PlainTrackHeader({
|
|
|
93
93
|
)}
|
|
94
94
|
{/* No `flex-1`: the name takes only the width it needs, so the clip
|
|
95
95
|
count sits against it rather than being pushed out to meet the
|
|
96
|
-
controls. The slack goes to the `ml-auto` group below instead.
|
|
97
|
-
|
|
98
|
-
Wraps rather than truncating: a truncated name needs a hover to be
|
|
99
|
-
read at all, and a tooltip is no use to a name you are scanning a
|
|
100
|
-
column of. `wrap-break-word` so a long single token breaks instead of
|
|
101
|
-
forcing the column wider. */}
|
|
96
|
+
controls. The slack goes to the `ml-auto` group below instead. */}
|
|
102
97
|
{showTrackLabel && (
|
|
103
|
-
<span className="min-w-0
|
|
98
|
+
<span title={trackLabel} className="min-w-0 truncate text-[11px] leading-tight">
|
|
99
|
+
{trackLabel}
|
|
100
|
+
</span>
|
|
104
101
|
)}
|
|
105
102
|
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
|
106
103
|
{/* `ml-auto` is what anchors the group right: it absorbs the slack the
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { resolvePlaybackAdapter } from "./playbackAdapterResolution";
|
|
5
|
+
|
|
6
|
+
function timeline(duration: number) {
|
|
7
|
+
return {
|
|
8
|
+
duration: vi.fn(() => duration),
|
|
9
|
+
time: vi.fn(() => 0),
|
|
10
|
+
seek: vi.fn(),
|
|
11
|
+
play: vi.fn(),
|
|
12
|
+
pause: vi.fn(),
|
|
13
|
+
isActive: vi.fn(() => false),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("resolvePlaybackAdapter", () => {
|
|
18
|
+
it("uses the __timelines key matching the document root, not the first key", () => {
|
|
19
|
+
const first = timeline(12);
|
|
20
|
+
const second = timeline(12);
|
|
21
|
+
const third = timeline(12);
|
|
22
|
+
const iframe = document.createElement("iframe");
|
|
23
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
24
|
+
doc.body.innerHTML = '<div data-composition-id="second" data-duration="10"></div>';
|
|
25
|
+
Object.defineProperty(iframe, "contentDocument", { configurable: true, value: doc });
|
|
26
|
+
const win = {
|
|
27
|
+
__timelines: { first, second, third },
|
|
28
|
+
} as never;
|
|
29
|
+
|
|
30
|
+
const adapter = resolvePlaybackAdapter(iframe, win, {
|
|
31
|
+
cache: { current: null },
|
|
32
|
+
warned: { current: false },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(adapter).not.toBeNull();
|
|
36
|
+
adapter?.seek(7);
|
|
37
|
+
expect(second.seek).toHaveBeenCalledWith(7);
|
|
38
|
+
expect(first.seek).not.toHaveBeenCalled();
|
|
39
|
+
expect(third.seek).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { useEffect } from "react";
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { createRoot } from "react-dom/client";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext";
|
|
8
|
+
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
9
|
+
import { makeAdapterWindow, makeFakeIframe } from "./timelinePlayerTestHarness";
|
|
10
|
+
import { usePlayerHandle, type PlayerHandle } from "./usePlayerHandle";
|
|
11
|
+
|
|
12
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
13
|
+
|
|
14
|
+
function renderHandle() {
|
|
15
|
+
let latest: PlayerHandle | null = null;
|
|
16
|
+
let context: NLEContextValue | null = null;
|
|
17
|
+
let renders = 0;
|
|
18
|
+
const host = document.createElement("div");
|
|
19
|
+
document.body.append(host);
|
|
20
|
+
const root = createRoot(host);
|
|
21
|
+
act(() => {
|
|
22
|
+
root.render(
|
|
23
|
+
<NLEProvider projectId="player-handle-test">
|
|
24
|
+
<HandleHarness
|
|
25
|
+
onContext={(value) => (context = value)}
|
|
26
|
+
onValue={(value) => (latest = value)}
|
|
27
|
+
onRender={() => renders++}
|
|
28
|
+
/>
|
|
29
|
+
</NLEProvider>,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
if (!latest || !context) throw new Error("usePlayerHandle did not mount");
|
|
33
|
+
return {
|
|
34
|
+
root,
|
|
35
|
+
getHandle: () => latest as PlayerHandle,
|
|
36
|
+
getContext: () => context as NLEContextValue,
|
|
37
|
+
getRenderCount: () => renders,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function HandleHarness({
|
|
42
|
+
onContext,
|
|
43
|
+
onValue,
|
|
44
|
+
onRender,
|
|
45
|
+
}: {
|
|
46
|
+
onContext: (value: NLEContextValue) => void;
|
|
47
|
+
onValue: (value: PlayerHandle) => void;
|
|
48
|
+
onRender: () => void;
|
|
49
|
+
}) {
|
|
50
|
+
onRender();
|
|
51
|
+
const context = useNLEContext();
|
|
52
|
+
const handle = usePlayerHandle();
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
onContext(context);
|
|
55
|
+
onValue(handle);
|
|
56
|
+
}, [context, handle, onContext, onValue]);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
usePlayerStore.getState().reset();
|
|
62
|
+
document.body.innerHTML = "";
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("usePlayerHandle", () => {
|
|
66
|
+
it("refuses seek before readiness and notifies structural changes independently", () => {
|
|
67
|
+
const { root, getHandle } = renderHandle();
|
|
68
|
+
const handle = getHandle();
|
|
69
|
+
|
|
70
|
+
expect(handle.ready).toBe(false);
|
|
71
|
+
expect(handle.seek(4)).toBe(false);
|
|
72
|
+
|
|
73
|
+
const structuralListener = vi.fn();
|
|
74
|
+
const unsubscribe = handle.subscribe(structuralListener);
|
|
75
|
+
act(() => usePlayerStore.setState({ timelineReady: true }));
|
|
76
|
+
expect(structuralListener).toHaveBeenCalledTimes(1);
|
|
77
|
+
act(() => usePlayerStore.setState({ duration: 8 }));
|
|
78
|
+
expect(structuralListener).toHaveBeenCalledTimes(2);
|
|
79
|
+
act(() =>
|
|
80
|
+
usePlayerStore.setState({
|
|
81
|
+
elements: [
|
|
82
|
+
{
|
|
83
|
+
id: "clip",
|
|
84
|
+
tag: "div",
|
|
85
|
+
start: 2,
|
|
86
|
+
duration: 3,
|
|
87
|
+
track: 0,
|
|
88
|
+
selector: "#clip",
|
|
89
|
+
sourceFile: "index.html",
|
|
90
|
+
compositionSrc: "compositions/intro.html",
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
expect(structuralListener).toHaveBeenCalledTimes(3);
|
|
96
|
+
expect(getHandle().elements).toEqual([
|
|
97
|
+
{
|
|
98
|
+
id: "clip",
|
|
99
|
+
start: 2,
|
|
100
|
+
end: 5,
|
|
101
|
+
selector: "#clip",
|
|
102
|
+
sourceFile: "index.html",
|
|
103
|
+
compositionSrc: "compositions/intro.html",
|
|
104
|
+
},
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
unsubscribe();
|
|
108
|
+
act(() => root.unmount());
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("uses the shell player to seek a real preview adapter after readiness", async () => {
|
|
112
|
+
const { root, getContext, getHandle } = renderHandle();
|
|
113
|
+
const { adapter, win } = makeAdapterWindow();
|
|
114
|
+
await act(async () => {
|
|
115
|
+
getContext().iframeRef.current = makeFakeIframe(win);
|
|
116
|
+
getContext().onIframeLoad();
|
|
117
|
+
await Promise.resolve();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(usePlayerStore.getState().timelineReady).toBe(true);
|
|
121
|
+
expect(getHandle().seek(4)).toBe(true);
|
|
122
|
+
expect(adapter.getTime()).toBe(4);
|
|
123
|
+
expect(usePlayerStore.getState().currentTime).toBe(4);
|
|
124
|
+
act(() => getHandle().play());
|
|
125
|
+
expect(getHandle().playing).toBe(true);
|
|
126
|
+
|
|
127
|
+
act(() => root.unmount());
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("keeps playback time out of React renders and forwards live time notifications", () => {
|
|
131
|
+
const { root, getHandle, getRenderCount } = renderHandle();
|
|
132
|
+
const initialRenderCount = getRenderCount();
|
|
133
|
+
const timeListener = vi.fn();
|
|
134
|
+
const structuralListener = vi.fn();
|
|
135
|
+
const unsubscribeTime = getHandle().subscribeTime(timeListener);
|
|
136
|
+
const unsubscribe = getHandle().subscribe(structuralListener);
|
|
137
|
+
|
|
138
|
+
act(() => {
|
|
139
|
+
usePlayerStore.setState({ currentTime: 2.5 });
|
|
140
|
+
liveTime.notify(7.25);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(getRenderCount()).toBe(initialRenderCount);
|
|
144
|
+
expect(getHandle().currentTime).toBe(2.5);
|
|
145
|
+
expect(timeListener).toHaveBeenCalledWith(7.25);
|
|
146
|
+
expect(structuralListener).not.toHaveBeenCalled();
|
|
147
|
+
|
|
148
|
+
unsubscribeTime();
|
|
149
|
+
unsubscribe();
|
|
150
|
+
act(() => root.unmount());
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { liveTime, usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { useNLEContext } from "../../components/nle/NLEContext";
|
|
4
|
+
|
|
5
|
+
export interface PlayerHandleElement {
|
|
6
|
+
id: string;
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
selector?: string;
|
|
10
|
+
sourceFile?: string;
|
|
11
|
+
compositionSrc?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type PlayerHandleListener = () => void;
|
|
15
|
+
export type PlayerHandleTimeListener = (time: number) => void;
|
|
16
|
+
|
|
17
|
+
export interface PlayerHandle {
|
|
18
|
+
ready: boolean;
|
|
19
|
+
playing: boolean;
|
|
20
|
+
seek: (seconds: number) => boolean;
|
|
21
|
+
play: () => void;
|
|
22
|
+
pause: () => void;
|
|
23
|
+
togglePlay: () => void;
|
|
24
|
+
readonly currentTime: number;
|
|
25
|
+
duration: number;
|
|
26
|
+
elements: PlayerHandleElement[];
|
|
27
|
+
subscribe: (listener: PlayerHandleListener) => () => void;
|
|
28
|
+
subscribeTime: (listener: PlayerHandleTimeListener) => () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toHandleElement(element: TimelineElement): PlayerHandleElement {
|
|
32
|
+
return {
|
|
33
|
+
id: element.id,
|
|
34
|
+
start: element.start,
|
|
35
|
+
end: element.start + element.duration,
|
|
36
|
+
selector: element.selector,
|
|
37
|
+
sourceFile: element.sourceFile,
|
|
38
|
+
compositionSrc: element.compositionSrc,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reads and controls the shell player. Call this under EditorShell/NLEProvider;
|
|
44
|
+
* the provider owns the mounted preview iframe and the single player instance.
|
|
45
|
+
*/
|
|
46
|
+
export function usePlayerHandle(): PlayerHandle {
|
|
47
|
+
const { play, pause, togglePlay, seek: seekPlayer } = useNLEContext();
|
|
48
|
+
const ready = usePlayerStore((state) => state.timelineReady);
|
|
49
|
+
const playing = usePlayerStore((state) => state.isPlaying);
|
|
50
|
+
const duration = usePlayerStore((state) => state.duration);
|
|
51
|
+
const elements = usePlayerStore((state) => state.elements);
|
|
52
|
+
|
|
53
|
+
const seek = useCallback(
|
|
54
|
+
(seconds: number) => {
|
|
55
|
+
if (!ready) return false;
|
|
56
|
+
return seekPlayer(seconds);
|
|
57
|
+
},
|
|
58
|
+
[ready, seekPlayer],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const subscribe = useCallback((listener: PlayerHandleListener) => {
|
|
62
|
+
let previous = usePlayerStore.getState();
|
|
63
|
+
return usePlayerStore.subscribe((state) => {
|
|
64
|
+
const structuralChange =
|
|
65
|
+
state.timelineReady !== previous.timelineReady ||
|
|
66
|
+
state.isPlaying !== previous.isPlaying ||
|
|
67
|
+
state.duration !== previous.duration ||
|
|
68
|
+
state.elements !== previous.elements;
|
|
69
|
+
previous = state;
|
|
70
|
+
if (structuralChange) listener();
|
|
71
|
+
});
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const subscribeTime = useCallback((listener: PlayerHandleTimeListener) => {
|
|
75
|
+
return liveTime.subscribe(listener);
|
|
76
|
+
}, []);
|
|
77
|
+
const handleElements = useMemo(() => elements.map(toHandleElement), [elements]);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
ready,
|
|
81
|
+
playing,
|
|
82
|
+
seek,
|
|
83
|
+
play,
|
|
84
|
+
pause,
|
|
85
|
+
togglePlay,
|
|
86
|
+
get currentTime() {
|
|
87
|
+
return usePlayerStore.getState().currentTime;
|
|
88
|
+
},
|
|
89
|
+
duration,
|
|
90
|
+
elements: handleElements,
|
|
91
|
+
subscribe,
|
|
92
|
+
subscribeTime,
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/player/index.ts
CHANGED
|
@@ -7,6 +7,13 @@ export { CompositionThumbnail } from "./components/CompositionThumbnail";
|
|
|
7
7
|
|
|
8
8
|
// Hooks
|
|
9
9
|
export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
|
|
10
|
+
export { usePlayerHandle } from "./hooks/usePlayerHandle";
|
|
11
|
+
export type {
|
|
12
|
+
PlayerHandle,
|
|
13
|
+
PlayerHandleElement,
|
|
14
|
+
PlayerHandleListener,
|
|
15
|
+
PlayerHandleTimeListener,
|
|
16
|
+
} from "./hooks/usePlayerHandle";
|
|
10
17
|
export { resolveIframe } from "./lib/timelineDOM";
|
|
11
18
|
|
|
12
19
|
// Store
|