@hyperframes/studio 0.8.77 → 0.8.79
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-Bx_Kw4Ey.js +464 -0
- package/dist/assets/{index-DcxWQ7Za.js → index-CJP92F4-.js} +1 -1
- package/dist/assets/{index-2GgHjQq-.js → index-DBKySIo7.js} +187 -187
- package/dist/assets/{index-CsFCRdMo.js → index-ORZXYB0k.js} +1 -1
- package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
- package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
- package/dist/index.d.ts +23 -15
- package/dist/index.html +1 -1
- package/dist/index.js +459 -257
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +8 -2
- package/src/hooks/timelineAudioGroupVolume.ts +21 -11
- package/src/hooks/timelineEditPermission.ts +47 -13
- package/src/hooks/timelineElementFxAttribute.ts +19 -13
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- package/src/hooks/useFrameCapture.test.tsx +67 -0
- package/src/hooks/useFrameCapture.ts +18 -9
- package/src/hooks/usePersistentEditHistory.test.ts +2 -2
- package/src/hooks/usePersistentEditHistory.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +59 -0
- package/src/hooks/useTimelineEditing.ts +42 -33
- package/src/index.ts +8 -0
- package/src/player/components/Player.test.ts +5 -0
- package/src/player/components/Player.tsx +1 -0
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/timelineCallbacks.ts +3 -2
- package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
- package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/telemetry/client.test.ts +26 -0
- package/src/telemetry/policy.test.ts +10 -0
- package/src/telemetry/policy.ts +7 -0
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- package/dist/assets/hyperframes-player-BSDTJ9EF.js +0 -464
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
package/src/index.ts
CHANGED
|
@@ -162,6 +162,7 @@ export type { StudioPendingEditsDrainResult } from "./utils/studioPendingEdits";
|
|
|
162
162
|
export type {
|
|
163
163
|
CanEditTimelineElement,
|
|
164
164
|
TimelineEditPermission,
|
|
165
|
+
TimelineEditOutcome,
|
|
165
166
|
} from "./hooks/timelineEditPermission";
|
|
166
167
|
export { useEditHistoryActions } from "./hooks/useEditHistoryActions";
|
|
167
168
|
export type {
|
|
@@ -183,3 +184,10 @@ export type { TimelinePaneProps } from "./components/nle/TimelinePane";
|
|
|
183
184
|
export { TimelineEditProvider } from "./contexts/TimelineEditContext";
|
|
184
185
|
export type { TimelineEditCallbacks } from "./player/components/timelineCallbacks";
|
|
185
186
|
export type { BlockedTimelineEditIntent } from "./player/components/timelineEditing";
|
|
187
|
+
|
|
188
|
+
export {
|
|
189
|
+
playSeamTransitionLoop,
|
|
190
|
+
type SeamTransitionFrameSource,
|
|
191
|
+
type SeamTransitionLoopHandle,
|
|
192
|
+
type SeamTransitionLoopOptions,
|
|
193
|
+
} from "@hyperframes/shader-transitions";
|
|
@@ -127,6 +127,11 @@ describe("preview errors", () => {
|
|
|
127
127
|
root = null;
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
it("puts the preview on the player's once-a-second paused heartbeat", async () => {
|
|
131
|
+
const { player } = await mountPlayer();
|
|
132
|
+
expect(player.hasAttribute("low-power-idle")).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
130
135
|
it("attaches lifecycle listeners before navigating the player", async () => {
|
|
131
136
|
await mountPlayer();
|
|
132
137
|
const srcIndex = lifecycleLog.indexOf("src");
|
|
@@ -317,6 +317,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
317
317
|
|
|
318
318
|
player.setAttribute("shader-capture-scale", "1");
|
|
319
319
|
player.setAttribute("shader-loading", "player");
|
|
320
|
+
player.setAttribute("low-power-idle", "");
|
|
320
321
|
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
|
321
322
|
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
|
322
323
|
player.style.width = "100%";
|
|
@@ -86,6 +86,19 @@ describe("VideoThumbnail", () => {
|
|
|
86
86
|
expect(host.querySelectorAll("img").length).toBeGreaterThan(0);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
it("spreads the frames across every tile so the strip reaches the clip's end", async () => {
|
|
90
|
+
const urls = Array.from({ length: 8 }, (_, index) => `blob:${index}`);
|
|
91
|
+
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
92
|
+
value: { kind: "filmstrip", urls, aspect: 16 / 9 },
|
|
93
|
+
weight: 256,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await render(500);
|
|
97
|
+
|
|
98
|
+
const tiles = [...host.querySelectorAll("img")].map((img) => img.getAttribute("src"));
|
|
99
|
+
expect(tiles).toEqual(["blob:0", "blob:2", "blob:4", "blob:5", "blob:7"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
89
102
|
it("issues a single decode job for a narrow clip", async () => {
|
|
90
103
|
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
91
104
|
value: { kind: "image", url: "blob:poster", aspect: 16 / 9 },
|
|
@@ -146,7 +146,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
146
146
|
{urls.length > 0 && (
|
|
147
147
|
<div className="absolute inset-0 flex">
|
|
148
148
|
{Array.from({ length: frameCount }, (_, index) => {
|
|
149
|
-
const src = urls[index
|
|
149
|
+
const src = urls[Math.round((index * (urls.length - 1)) / Math.max(1, frameCount - 1))];
|
|
150
150
|
return (
|
|
151
151
|
<div
|
|
152
152
|
key={index}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// fallow-ignore-file code-duplication
|
|
2
2
|
// fallow-ignore-file dead-code
|
|
3
|
+
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
|
|
3
4
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
5
|
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
|
5
6
|
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
|
@@ -89,7 +90,7 @@ export interface TimelineEditCallbacks {
|
|
|
89
90
|
attr: string,
|
|
90
91
|
value: string | null,
|
|
91
92
|
label: string,
|
|
92
|
-
) => Promise<void>;
|
|
93
|
+
) => Promise<TimelineEditOutcome | void>;
|
|
93
94
|
/** C1's ungrouped-track FX pointer: "Group these clips" — write
|
|
94
95
|
* `data-audio-group` on every one of them, atomically. Same shape B6's
|
|
95
96
|
* carve auto-grouping uses. */
|
|
@@ -110,7 +111,7 @@ export interface TimelineEditCallbacks {
|
|
|
110
111
|
attr: string,
|
|
111
112
|
value: string | null,
|
|
112
113
|
label: string,
|
|
113
|
-
) => Promise<void>;
|
|
114
|
+
) => Promise<TimelineEditOutcome | void>;
|
|
114
115
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
115
116
|
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
116
117
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
6
|
+
import {
|
|
7
|
+
attachIframeWindow,
|
|
8
|
+
makeAdapterWindow,
|
|
9
|
+
renderTimelinePlayerHarness,
|
|
10
|
+
resetPlayerStore,
|
|
11
|
+
} from "./timelinePlayerTestHarness";
|
|
12
|
+
|
|
13
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
document.body.innerHTML = "";
|
|
18
|
+
resetPlayerStore();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("useTimelinePlayer tab hidden while playing", () => {
|
|
22
|
+
it("pauses on the adapter's time, like any other pause", () => {
|
|
23
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
24
|
+
const { adapter, win } = makeAdapterWindow();
|
|
25
|
+
attachIframeWindow(api, win);
|
|
26
|
+
act(() => api.play());
|
|
27
|
+
adapter.seek(6);
|
|
28
|
+
|
|
29
|
+
vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
|
30
|
+
act(() => void document.dispatchEvent(new Event("visibilitychange")));
|
|
31
|
+
|
|
32
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
33
|
+
expect(usePlayerStore.getState().currentTime).toBe(6);
|
|
34
|
+
act(() => root.unmount());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("stops a reverse shuttle for good", async () => {
|
|
38
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
39
|
+
const { adapter, win } = makeAdapterWindow();
|
|
40
|
+
attachIframeWindow(api, win);
|
|
41
|
+
adapter.seek(20);
|
|
42
|
+
act(() => void window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyJ", key: "j" })));
|
|
43
|
+
await act(() => new Promise((r) => setTimeout(r, 100)));
|
|
44
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
45
|
+
|
|
46
|
+
vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
|
47
|
+
act(() => void document.dispatchEvent(new Event("visibilitychange")));
|
|
48
|
+
const stoppedAt = adapter.getTime();
|
|
49
|
+
await act(() => new Promise((r) => setTimeout(r, 200)));
|
|
50
|
+
|
|
51
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
52
|
+
expect(adapter.getTime()).toBe(stoppedAt);
|
|
53
|
+
expect(usePlayerStore.getState().currentTime).toBe(stoppedAt);
|
|
54
|
+
act(() => root.unmount());
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("does not carry the shuttle speed past the pause", () => {
|
|
58
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
59
|
+
attachIframeWindow(api, makeAdapterWindow().win);
|
|
60
|
+
const pressL = () =>
|
|
61
|
+
act(
|
|
62
|
+
() => void window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyL", key: "l" })),
|
|
63
|
+
);
|
|
64
|
+
pressL();
|
|
65
|
+
pressL();
|
|
66
|
+
expect(usePlayerStore.getState().playbackRate).toBe(2);
|
|
67
|
+
|
|
68
|
+
vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
|
69
|
+
act(() => void document.dispatchEvent(new Event("visibilitychange")));
|
|
70
|
+
pressL();
|
|
71
|
+
|
|
72
|
+
expect(usePlayerStore.getState().playbackRate).toBe(1);
|
|
73
|
+
act(() => root.unmount());
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -95,6 +95,42 @@ it.each([30, 0])(
|
|
|
95
95
|
},
|
|
96
96
|
);
|
|
97
97
|
|
|
98
|
+
it("settles a load whose runtime learns its duration after its only readiness post", async () => {
|
|
99
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
100
|
+
let duration = 0;
|
|
101
|
+
const { adapter, win } = makeAdapterWindow();
|
|
102
|
+
adapter.getDuration = () => duration;
|
|
103
|
+
act(() => {
|
|
104
|
+
api.iframeRef.current = makeFakeIframe(win);
|
|
105
|
+
api.onIframeLoad();
|
|
106
|
+
});
|
|
107
|
+
postFromRuntime(win, { type: "state" });
|
|
108
|
+
expect(adapter.pause).not.toHaveBeenCalled();
|
|
109
|
+
|
|
110
|
+
// No further post comes: a paused low-power preview checks in only once a second.
|
|
111
|
+
duration = 30;
|
|
112
|
+
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
|
|
113
|
+
expect(adapter.pause).toHaveBeenCalled();
|
|
114
|
+
act(() => root.unmount());
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each(["unmounts", "resets"])("stops retrying a pending load once the player %s", async (how) => {
|
|
118
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
119
|
+
const { adapter, win } = makeAdapterWindow();
|
|
120
|
+
let lookups = 0;
|
|
121
|
+
adapter.getDuration = () => (lookups++, 0);
|
|
122
|
+
act(() => {
|
|
123
|
+
api.iframeRef.current = makeFakeIframe(win);
|
|
124
|
+
api.onIframeLoad();
|
|
125
|
+
});
|
|
126
|
+
postFromRuntime(win, { type: "state" });
|
|
127
|
+
act(() => (how === "resets" ? api.resetPlayer() : root.unmount()));
|
|
128
|
+
const atStop = lookups;
|
|
129
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
130
|
+
expect(lookups).toBe(atStop);
|
|
131
|
+
if (how === "resets") act(() => root.unmount());
|
|
132
|
+
});
|
|
133
|
+
|
|
98
134
|
it("does not let the blank page's load step enable Play for the preview that replaces it", async () => {
|
|
99
135
|
const { api, root } = renderTimelinePlayerHarness();
|
|
100
136
|
// The iframe's first load is about:blank, before the runtime exists; its load step waits for runtime messages.
|
|
@@ -362,21 +362,25 @@ export function useTimelinePlayer({
|
|
|
362
362
|
seek,
|
|
363
363
|
});
|
|
364
364
|
|
|
365
|
-
const {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
365
|
+
const {
|
|
366
|
+
processTimelineMessageRef,
|
|
367
|
+
enrichMissingCompositionsRef,
|
|
368
|
+
onIframeLoad,
|
|
369
|
+
cancelPendingLoad,
|
|
370
|
+
} = useTimelineSyncCallbacks({
|
|
371
|
+
iframeRef,
|
|
372
|
+
probeIntervalRef,
|
|
373
|
+
pendingSeekRef,
|
|
374
|
+
isRefreshingRef,
|
|
375
|
+
getAdapter,
|
|
376
|
+
syncTimelineElements,
|
|
377
|
+
setDuration,
|
|
378
|
+
setCurrentTime,
|
|
379
|
+
requestTimelineReady,
|
|
380
|
+
setIsPlaying,
|
|
381
|
+
attachIframeShortcutListeners,
|
|
382
|
+
applyPreviewAudioState,
|
|
383
|
+
});
|
|
380
384
|
|
|
381
385
|
// Full-reload edits load behind a hidden shadow iframe (useShadowPreviewReload.ts).
|
|
382
386
|
const {
|
|
@@ -452,8 +456,8 @@ export function useTimelinePlayer({
|
|
|
452
456
|
applyPreviewVariablesToUrl(url);
|
|
453
457
|
beginShadowReload(url.toString());
|
|
454
458
|
}, [saveSeekPosition, getAdapter, beginShadowReload]);
|
|
455
|
-
const
|
|
456
|
-
|
|
459
|
+
const pauseRef = useRef(pause);
|
|
460
|
+
pauseRef.current = pause;
|
|
457
461
|
|
|
458
462
|
useMountEffect(() => {
|
|
459
463
|
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
|
@@ -469,14 +473,7 @@ export function useTimelinePlayer({
|
|
|
469
473
|
});
|
|
470
474
|
|
|
471
475
|
const handleVisibilityChange = () => {
|
|
472
|
-
if (document.hidden && usePlayerStore.getState().isPlaying)
|
|
473
|
-
const adapter = getAdapterRef.current?.();
|
|
474
|
-
if (adapter) {
|
|
475
|
-
adapter.pause();
|
|
476
|
-
setIsPlaying(false);
|
|
477
|
-
stopRAFLoop();
|
|
478
|
-
}
|
|
479
|
-
}
|
|
476
|
+
if (document.hidden && usePlayerStore.getState().isPlaying) pauseRef.current();
|
|
480
477
|
};
|
|
481
478
|
|
|
482
479
|
window.addEventListener("keydown", handleWindowKeyDown, true);
|
|
@@ -494,16 +491,16 @@ export function useTimelinePlayer({
|
|
|
494
491
|
stopReverseLoop();
|
|
495
492
|
stopScrubPreviewAudio();
|
|
496
493
|
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
|
|
497
|
-
|
|
494
|
+
cancelPendingLoad();
|
|
498
495
|
};
|
|
499
496
|
});
|
|
500
497
|
|
|
501
498
|
const resetPlayer = useCallback(() => {
|
|
502
499
|
stopRAFLoop();
|
|
503
500
|
stopReverseLoop();
|
|
504
|
-
|
|
501
|
+
cancelPendingLoad();
|
|
505
502
|
usePlayerStore.getState().reset();
|
|
506
|
-
}, [stopRAFLoop, stopReverseLoop]);
|
|
503
|
+
}, [stopRAFLoop, stopReverseLoop, cancelPendingLoad]);
|
|
507
504
|
|
|
508
505
|
useEffect(() => {
|
|
509
506
|
return usePlayerStore.subscribe((state, prev) => {
|
|
@@ -244,7 +244,7 @@ export function useTimelineSyncCallbacks({
|
|
|
244
244
|
adapter.pause();
|
|
245
245
|
const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef);
|
|
246
246
|
const commit = () => {
|
|
247
|
-
// Keep non-React listeners such as the
|
|
247
|
+
// Keep non-React listeners such as the time display in sync
|
|
248
248
|
// with the initial adapter seek on iframe load.
|
|
249
249
|
liveTime.notify(startTime);
|
|
250
250
|
syncAdapterDuration(adapter, setDuration);
|
|
@@ -309,28 +309,36 @@ export function useTimelineSyncCallbacks({
|
|
|
309
309
|
// Listen for those instead of polling.
|
|
310
310
|
const iframe = iframeRef.current;
|
|
311
311
|
let settled = false;
|
|
312
|
+
let retryFrame = 0;
|
|
313
|
+
const stopWaiting = () => {
|
|
314
|
+
window.removeEventListener("message", onMessage);
|
|
315
|
+
cancelAnimationFrame(retryFrame);
|
|
316
|
+
};
|
|
312
317
|
|
|
313
318
|
const trySettle = () => {
|
|
314
319
|
if (settled) return;
|
|
315
320
|
if (initializeAdapter(context)) {
|
|
316
321
|
settled = true;
|
|
317
|
-
|
|
322
|
+
stopWaiting();
|
|
318
323
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
324
|
+
return;
|
|
319
325
|
}
|
|
326
|
+
cancelAnimationFrame(retryFrame);
|
|
327
|
+
retryFrame = requestAnimationFrame(trySettle);
|
|
320
328
|
};
|
|
321
329
|
|
|
322
330
|
const onMessage = (e: MessageEvent) => {
|
|
323
331
|
if (isPreviewReadinessMessage(e, iframe)) trySettle();
|
|
324
332
|
};
|
|
325
333
|
window.addEventListener("message", onMessage);
|
|
326
|
-
stopWaitingRef.current =
|
|
334
|
+
stopWaitingRef.current = stopWaiting;
|
|
327
335
|
|
|
328
336
|
// Safety net: if no message arrives within 5s, try one last time then give up.
|
|
329
337
|
probeIntervalRef.current = setTimeout(() => {
|
|
330
338
|
if (!settled) {
|
|
331
339
|
trySettle();
|
|
332
340
|
}
|
|
333
|
-
|
|
341
|
+
stopWaiting();
|
|
334
342
|
if (!settled) onLoadGiveUp?.(context);
|
|
335
343
|
revealIframe(iframeRef.current);
|
|
336
344
|
}, 5000) as unknown as ReturnType<typeof setInterval>;
|
|
@@ -5,6 +5,7 @@ import { decodeVideoThumbnail, videoThumbnailTimestamps } from "./thumbnailVideo
|
|
|
5
5
|
|
|
6
6
|
const dispose = vi.fn();
|
|
7
7
|
const canvasesAtTimestamps = vi.fn();
|
|
8
|
+
const getKeyPacket = vi.fn(async (_time: number) => null as { timestamp: number } | null);
|
|
8
9
|
const input = {
|
|
9
10
|
getPrimaryVideoTrack: vi.fn(),
|
|
10
11
|
dispose,
|
|
@@ -22,10 +23,25 @@ vi.mock("mediabunny", () => ({
|
|
|
22
23
|
CanvasSink: class {
|
|
23
24
|
canvasesAtTimestamps = canvasesAtTimestamps;
|
|
24
25
|
},
|
|
26
|
+
EncodedPacketSink: class {
|
|
27
|
+
getKeyPacket = getKeyPacket;
|
|
28
|
+
},
|
|
25
29
|
}));
|
|
26
30
|
|
|
31
|
+
function recordDecodes(decoded: number[][]): void {
|
|
32
|
+
canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
|
|
33
|
+
const run: number[] = [];
|
|
34
|
+
decoded.push(run);
|
|
35
|
+
for await (const time of timestamps) {
|
|
36
|
+
run.push(time);
|
|
37
|
+
yield { canvas: document.createElement("canvas") };
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
beforeEach(() => {
|
|
28
43
|
vi.clearAllMocks();
|
|
44
|
+
getKeyPacket.mockImplementation(async () => null);
|
|
29
45
|
vi.spyOn(URL, "createObjectURL").mockReturnValueOnce("blob:one").mockReturnValueOnce("blob:two");
|
|
30
46
|
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
31
47
|
HTMLCanvasElement.prototype.toBlob = function toBlob(callback) {
|
|
@@ -52,17 +68,14 @@ describe("videoThumbnailTimestamps", () => {
|
|
|
52
68
|
|
|
53
69
|
describe("decodeVideoThumbnail", () => {
|
|
54
70
|
it("extracts sparse frames, returns object URLs, and disposes once", async () => {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
expect(timestamps).toEqual([2, 8]);
|
|
58
|
-
yield { canvas, timestamp: 2, duration: 1 };
|
|
59
|
-
yield { canvas, timestamp: 8, duration: 1 };
|
|
60
|
-
});
|
|
71
|
+
const decoded: number[][] = [];
|
|
72
|
+
recordDecodes(decoded);
|
|
61
73
|
const result = await decodeVideoThumbnail(
|
|
62
74
|
{ source: "/clip.mp4", sourceStart: 2, sourceRangeDuration: 6, frameCount: 2 },
|
|
63
75
|
new AbortController().signal,
|
|
64
76
|
);
|
|
65
77
|
|
|
78
|
+
expect(decoded).toEqual([[2, 8]]);
|
|
66
79
|
expect(result.value).toEqual({
|
|
67
80
|
kind: "filmstrip",
|
|
68
81
|
urls: ["blob:one", "blob:two"],
|
|
@@ -74,6 +87,89 @@ describe("decodeVideoThumbnail", () => {
|
|
|
74
87
|
expect(dispose).toHaveBeenCalledTimes(1);
|
|
75
88
|
});
|
|
76
89
|
|
|
90
|
+
it("decodes each strip frame at its keyframe unless that keyframe is before the clip's range", async () => {
|
|
91
|
+
getKeyPacket.mockImplementation(async (time) => ({ timestamp: Math.floor(time / 4) * 4 }));
|
|
92
|
+
const decoded: number[][] = [];
|
|
93
|
+
recordDecodes(decoded);
|
|
94
|
+
await decodeVideoThumbnail(
|
|
95
|
+
{ source: "/clip.mp4", sourceStart: 2, sourceRangeDuration: 8, frameCount: 3 },
|
|
96
|
+
new AbortController().signal,
|
|
97
|
+
);
|
|
98
|
+
expect(decoded).toEqual([[2, 4, 8]]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("keeps a slot's own time when its keyframe is more than half a slot earlier", async () => {
|
|
102
|
+
getKeyPacket.mockImplementation(async () => ({ timestamp: 0 }));
|
|
103
|
+
const decoded: number[][] = [];
|
|
104
|
+
recordDecodes(decoded);
|
|
105
|
+
await decodeVideoThumbnail(
|
|
106
|
+
{ source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 3 },
|
|
107
|
+
new AbortController().signal,
|
|
108
|
+
);
|
|
109
|
+
await decodeVideoThumbnail(
|
|
110
|
+
{ source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 1 },
|
|
111
|
+
new AbortController().signal,
|
|
112
|
+
);
|
|
113
|
+
expect(decoded).toEqual([[0, 5, 10], [5]]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("looks up each keyframe just before decoding it", async () => {
|
|
117
|
+
const events: string[] = [];
|
|
118
|
+
getKeyPacket.mockImplementation(async (time) => {
|
|
119
|
+
events.push(`key ${time}`);
|
|
120
|
+
return { timestamp: time };
|
|
121
|
+
});
|
|
122
|
+
canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
|
|
123
|
+
for await (const time of timestamps) {
|
|
124
|
+
events.push(`frame ${time}`);
|
|
125
|
+
yield { canvas: document.createElement("canvas") };
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
await decodeVideoThumbnail(
|
|
129
|
+
{ source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 3 },
|
|
130
|
+
new AbortController().signal,
|
|
131
|
+
);
|
|
132
|
+
expect(events).toEqual(["key 0", "frame 0", "key 5", "frame 5", "key 10", "frame 10"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("ends the decode times without throwing when cancelled during a keyframe lookup", async () => {
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
getKeyPacket.mockImplementation(async () => (controller.abort(), null));
|
|
138
|
+
const decoded: number[] = [];
|
|
139
|
+
let timesEnded = false;
|
|
140
|
+
canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
|
|
141
|
+
for await (const time of timestamps) {
|
|
142
|
+
decoded.push(time);
|
|
143
|
+
yield { canvas: document.createElement("canvas") };
|
|
144
|
+
}
|
|
145
|
+
timesEnded = true;
|
|
146
|
+
});
|
|
147
|
+
await expect(
|
|
148
|
+
decodeVideoThumbnail(
|
|
149
|
+
{ source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 3 },
|
|
150
|
+
controller.signal,
|
|
151
|
+
),
|
|
152
|
+
).rejects.toThrow("Aborted");
|
|
153
|
+
expect(getKeyPacket).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(decoded).toEqual([]);
|
|
155
|
+
expect(timesEnded).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("spreads a trimmed strip across its range when the file reports no duration", async () => {
|
|
159
|
+
input.getPrimaryVideoTrack.mockResolvedValue({
|
|
160
|
+
getDisplayWidth: vi.fn(async () => 1080),
|
|
161
|
+
getDisplayHeight: vi.fn(async () => 1920),
|
|
162
|
+
getDurationFromMetadata: vi.fn(async () => null),
|
|
163
|
+
});
|
|
164
|
+
const decoded: number[][] = [];
|
|
165
|
+
recordDecodes(decoded);
|
|
166
|
+
await decodeVideoThumbnail(
|
|
167
|
+
{ source: "/clip.mp4", sourceStart: 5, sourceRangeDuration: 5, frameCount: 3 },
|
|
168
|
+
new AbortController().signal,
|
|
169
|
+
);
|
|
170
|
+
expect(decoded).toEqual([[5, 7.5, 10]]);
|
|
171
|
+
});
|
|
172
|
+
|
|
77
173
|
it("releases input and degrades when the source has no video track", async () => {
|
|
78
174
|
input.getPrimaryVideoTrack.mockResolvedValue(null);
|
|
79
175
|
await expect(
|
|
@@ -44,7 +44,7 @@ interface DecodedResources {
|
|
|
44
44
|
|
|
45
45
|
interface ThumbnailCanvasSink {
|
|
46
46
|
canvasesAtTimestamps(
|
|
47
|
-
timestamps: number
|
|
47
|
+
timestamps: AsyncIterable<number>,
|
|
48
48
|
): AsyncIterable<{ canvas: HTMLCanvasElement | OffscreenCanvas } | null>;
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -77,7 +77,7 @@ function targetDimensions(
|
|
|
77
77
|
|
|
78
78
|
async function decodeFrames(
|
|
79
79
|
sink: ThumbnailCanvasSink,
|
|
80
|
-
timestamps: number
|
|
80
|
+
timestamps: AsyncIterable<number>,
|
|
81
81
|
signal: AbortSignal,
|
|
82
82
|
resources: DecodedResources,
|
|
83
83
|
): Promise<void> {
|
|
@@ -89,6 +89,7 @@ async function decodeFrames(
|
|
|
89
89
|
throwIfAborted(signal);
|
|
90
90
|
resources.urls.push(URL.createObjectURL(blob));
|
|
91
91
|
}
|
|
92
|
+
throwIfAborted(signal);
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
function loadedResult(
|
|
@@ -138,8 +139,12 @@ export async function decodeVideoThumbnail(
|
|
|
138
139
|
}
|
|
139
140
|
const metadataDuration = await track.getDurationFromMetadata({ skipLiveWait: true });
|
|
140
141
|
throwIfAborted(signal);
|
|
141
|
-
const
|
|
142
|
-
const
|
|
142
|
+
const requestedStart = Math.max(0, request.sourceStart ?? 0);
|
|
143
|
+
const sourceDuration = Math.max(
|
|
144
|
+
0,
|
|
145
|
+
metadataDuration ?? requestedStart + (request.sourceRangeDuration ?? 0),
|
|
146
|
+
);
|
|
147
|
+
const sourceStart = Math.min(requestedStart, sourceDuration);
|
|
143
148
|
const requestedDuration =
|
|
144
149
|
request.sourceRangeDuration ?? Math.max(0, sourceDuration - sourceStart);
|
|
145
150
|
const duration = Math.min(
|
|
@@ -151,6 +156,16 @@ export async function decodeVideoThumbnail(
|
|
|
151
156
|
duration,
|
|
152
157
|
Math.min(request.frameCount, budgets.richPreviewFrameCount),
|
|
153
158
|
);
|
|
159
|
+
const keys = new mediabunny.EncodedPacketSink(track);
|
|
160
|
+
const maxKeyframeLead = duration / Math.max(2, timestamps.length - 1) / 2;
|
|
161
|
+
async function* decodeTimesAtNearbyKeyframes() {
|
|
162
|
+
for (const time of timestamps) {
|
|
163
|
+
const key = await keys.getKeyPacket(time, { metadataOnly: true });
|
|
164
|
+
if (signal.aborted) return;
|
|
165
|
+
const near = key && key.timestamp >= sourceStart && time - key.timestamp <= maxKeyframeLead;
|
|
166
|
+
yield near ? key.timestamp : time;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
154
169
|
const aspect = displayWidth / displayHeight;
|
|
155
170
|
const target = targetDimensions(aspect, budgets);
|
|
156
171
|
const sink = new mediabunny.CanvasSink(track, {
|
|
@@ -159,7 +174,7 @@ export async function decodeVideoThumbnail(
|
|
|
159
174
|
fit: request.fit ?? "cover",
|
|
160
175
|
poolSize: 1,
|
|
161
176
|
});
|
|
162
|
-
await decodeFrames(sink,
|
|
177
|
+
await decodeFrames(sink, decodeTimesAtNearbyKeyframes(), signal, resources);
|
|
163
178
|
return loadedResult(resources, aspect, target.width, target.height);
|
|
164
179
|
} catch (error) {
|
|
165
180
|
releaseDecodedResources(resources);
|
|
@@ -30,6 +30,7 @@ describe("studio client shouldTrack", () => {
|
|
|
30
30
|
setDev(false);
|
|
31
31
|
setNoTelemetry(undefined);
|
|
32
32
|
localStorage.clear();
|
|
33
|
+
delete window.__HF_CLI_TELEMETRY_DISABLED;
|
|
33
34
|
vi.unstubAllGlobals();
|
|
34
35
|
});
|
|
35
36
|
|
|
@@ -89,3 +90,28 @@ describe("studio client shouldTrack", () => {
|
|
|
89
90
|
expect(shouldTrack()).toBe(false);
|
|
90
91
|
});
|
|
91
92
|
});
|
|
93
|
+
|
|
94
|
+
it("suppresses actual render events in a CLI-opted-out Studio while the enabled control sends", async () => {
|
|
95
|
+
vi.useFakeTimers();
|
|
96
|
+
vi.resetModules();
|
|
97
|
+
setDev(false);
|
|
98
|
+
setNoTelemetry(undefined);
|
|
99
|
+
localStorage.clear();
|
|
100
|
+
const send = vi.fn().mockResolvedValue(new Response());
|
|
101
|
+
vi.stubGlobal("fetch", send);
|
|
102
|
+
try {
|
|
103
|
+
const { trackEvent } = await import("./client");
|
|
104
|
+
window.__HF_CLI_TELEMETRY_DISABLED = true;
|
|
105
|
+
trackEvent("render_complete", { duration: 1 });
|
|
106
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
107
|
+
expect(send).not.toHaveBeenCalled();
|
|
108
|
+
window.__HF_CLI_TELEMETRY_DISABLED = false;
|
|
109
|
+
trackEvent("render_complete", { duration: 1 });
|
|
110
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
111
|
+
expect(send).toHaveBeenCalledOnce();
|
|
112
|
+
} finally {
|
|
113
|
+
delete window.__HF_CLI_TELEMETRY_DISABLED;
|
|
114
|
+
vi.useRealTimers();
|
|
115
|
+
vi.unstubAllGlobals();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
@@ -15,6 +15,7 @@ describe("browserTelemetryAllowed", () => {
|
|
|
15
15
|
|
|
16
16
|
beforeEach(async () => {
|
|
17
17
|
localStorage.clear();
|
|
18
|
+
delete window.__HF_CLI_TELEMETRY_DISABLED;
|
|
18
19
|
vi.resetModules();
|
|
19
20
|
// vitest sets import.meta.env.DEV; the policy suppresses under it, so the
|
|
20
21
|
// baseline has to be an explicitly production-like env.
|
|
@@ -28,6 +29,15 @@ describe("browserTelemetryAllowed", () => {
|
|
|
28
29
|
vi.unstubAllEnvs();
|
|
29
30
|
});
|
|
30
31
|
|
|
32
|
+
it("refuses a CLI opt-out even when browser storage permits telemetry", () => {
|
|
33
|
+
expect(browserTelemetryAllowed()).toBe(true);
|
|
34
|
+
window.__HF_CLI_TELEMETRY_DISABLED = true;
|
|
35
|
+
expect(browserTelemetryAllowed()).toBe(false);
|
|
36
|
+
expect(localStorage.getItem(DOCUMENTED_OPT_OUT)).toBeNull();
|
|
37
|
+
window.__HF_CLI_TELEMETRY_DISABLED = false;
|
|
38
|
+
expect(browserTelemetryAllowed()).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
31
41
|
it("allows telemetry with no control set", () => {
|
|
32
42
|
expect(browserTelemetryAllowed()).toBe(true);
|
|
33
43
|
});
|
package/src/telemetry/policy.ts
CHANGED
|
@@ -92,10 +92,17 @@ export function browserTelemetryAllowed(): boolean {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
declare global {
|
|
96
|
+
interface Window {
|
|
97
|
+
__HF_CLI_TELEMETRY_DISABLED?: boolean;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
95
101
|
function allowed(): boolean {
|
|
96
102
|
return (
|
|
97
103
|
isApiKeyConfigured() &&
|
|
98
104
|
!isBuildTimeOptOut() &&
|
|
105
|
+
!(typeof window !== "undefined" && window.__HF_CLI_TELEMETRY_DISABLED === true) &&
|
|
99
106
|
!isViteDevMode() &&
|
|
100
107
|
!isOptedOut() &&
|
|
101
108
|
!isLegacyOptedOut() &&
|