@hyperframes/studio 0.8.78 → 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-BdnQGwG5.js → hyperframes-player-Bx_Kw4Ey.js} +3 -3
- package/dist/assets/{index-C_n__Uh3.js → index-CJP92F4-.js} +1 -1
- package/dist/assets/{index-CX_4MYbk.js → index-DBKySIo7.js} +187 -187
- package/dist/assets/{index-DdpuHaGN.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 +22 -15
- package/dist/index.html +1 -1
- package/dist/index.js +452 -256
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- 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 +1 -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/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
|
@@ -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);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ensureHfIds, isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
|
|
2
|
+
|
|
3
|
+
// Stamped as the preview stamps the files it serves, so a live element's hf-id finds its source.
|
|
4
|
+
export function parseSavedSource(html: string): Document {
|
|
5
|
+
return new DOMParser().parseFromString(ensureHfIds(html), "text/html");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const rootsByDoc = new WeakMap<Document, ParentNode[]>();
|
|
9
|
+
|
|
10
|
+
// A plain template is a runtime clone source, so ids inside it repeat across clones.
|
|
11
|
+
function searchRoots(root: ParentNode): ParentNode[] {
|
|
12
|
+
const templates = Array.from(root.querySelectorAll("template")).filter(isCompositionTemplate);
|
|
13
|
+
return [root, ...templates.flatMap((template) => searchRoots(template.content))];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function findByAttribute(doc: Document, name: string, value: string): Element | null {
|
|
17
|
+
let roots = rootsByDoc.get(doc);
|
|
18
|
+
if (!roots) rootsByDoc.set(doc, (roots = searchRoots(doc)));
|
|
19
|
+
const selector = `[${name}="${value.replace(/["\\]/g, "\\$&")}"]`;
|
|
20
|
+
for (const root of roots) {
|
|
21
|
+
const match = root.querySelector(selector);
|
|
22
|
+
if (match) return match;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function findAuthoredElement(doc: Document, live: Element): Element | null {
|
|
28
|
+
const hfId = live.getAttribute("data-hf-id");
|
|
29
|
+
if (hfId) return findByAttribute(doc, "data-hf-id", hfId);
|
|
30
|
+
return live.id ? findByAttribute(doc, "id", live.id) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function findAuthoredElementById(doc: Document, live: Element): Element | null {
|
|
34
|
+
return live.id ? findByAttribute(doc, "id", live.id) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const PATH_ATTRS = ["src", "href"];
|
|
38
|
+
const CSS_URL = /\burl\(\s*(["']?)([^)"'\s](?:[^)"']*[^)"'\s])?)\1\s*\)/g;
|
|
39
|
+
|
|
40
|
+
function isRelative(path: string): boolean {
|
|
41
|
+
return !!path && !/^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(path);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toProjectPath(sourceFile: string, path: string): string {
|
|
45
|
+
const parts = sourceFile.split("/").slice(0, -1);
|
|
46
|
+
for (const part of path.split("/")) {
|
|
47
|
+
if (part === "..") parts.pop();
|
|
48
|
+
else if (part !== "." && part !== "") parts.push(part);
|
|
49
|
+
}
|
|
50
|
+
return parts.join("/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function livePathsOf(live: Element): Set<string> {
|
|
54
|
+
const paths = new Set<string>();
|
|
55
|
+
for (const el of [live, ...Array.from(live.querySelectorAll("*"))]) {
|
|
56
|
+
for (const attr of PATH_ATTRS) paths.add(el.getAttribute(attr) ?? "");
|
|
57
|
+
for (const match of (el.getAttribute("style") ?? "").matchAll(CSS_URL)) paths.add(match[2]);
|
|
58
|
+
}
|
|
59
|
+
return paths;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The preview inlines a sub-composition with its relative asset paths rebased to the project
|
|
63
|
+
// root (rewriteAssetPath in @hyperframes/parsers, which is not browser-safe); copy does the same.
|
|
64
|
+
export function authoredMarkup(authored: Element, live: Element, sourceFile: string): string {
|
|
65
|
+
if (!sourceFile.includes("/")) return authored.outerHTML;
|
|
66
|
+
const livePaths = livePathsOf(live);
|
|
67
|
+
const rebase = (path: string): string => {
|
|
68
|
+
if (!isRelative(path)) return path;
|
|
69
|
+
const cut = path.search(/[?#]/);
|
|
70
|
+
const suffix = cut < 0 ? "" : path.slice(cut);
|
|
71
|
+
const rebased = toProjectPath(sourceFile, cut < 0 ? path : path.slice(0, cut)) + suffix;
|
|
72
|
+
return path.startsWith("../") || livePaths.has(rebased) ? rebased : path;
|
|
73
|
+
};
|
|
74
|
+
const copy = authored.cloneNode(true) as Element;
|
|
75
|
+
for (const el of [copy, ...Array.from(copy.querySelectorAll("*"))]) {
|
|
76
|
+
for (const attr of PATH_ATTRS) {
|
|
77
|
+
const value = el.getAttribute(attr);
|
|
78
|
+
if (value) el.setAttribute(attr, rebase(value.trim()));
|
|
79
|
+
}
|
|
80
|
+
const style = el.getAttribute("style");
|
|
81
|
+
if (style) {
|
|
82
|
+
el.setAttribute(
|
|
83
|
+
"style",
|
|
84
|
+
style.replace(
|
|
85
|
+
CSS_URL,
|
|
86
|
+
(_, quote: string, url: string) => `url(${quote}${rebase(url)}${quote})`,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return copy.outerHTML;
|
|
92
|
+
}
|
|
@@ -488,6 +488,35 @@ describe("applySoftReload authored-opacity restore", () => {
|
|
|
488
488
|
expect(el.style.getPropertyPriority("opacity")).toBe("");
|
|
489
489
|
});
|
|
490
490
|
|
|
491
|
+
it("keeps the stamp for an element whose hf-id the written file lacks, even if it shares an id", () => {
|
|
492
|
+
const el = document.createElement("img");
|
|
493
|
+
el.id = "title-card";
|
|
494
|
+
el.setAttribute("data-hf-id", "hf-from-another-file");
|
|
495
|
+
el.setAttribute("data-hf-authored-opacity", "0.5");
|
|
496
|
+
el.style.opacity = "0";
|
|
497
|
+
|
|
498
|
+
const opacity = restoreOpacity(
|
|
499
|
+
el,
|
|
500
|
+
'<html><body><img id="title-card" style="opacity: 0.9"></body></html>',
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
expect(opacity).toBe("0.5");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("does not take a runtime clone's opacity from its plain template", () => {
|
|
507
|
+
const el = document.createElement("li");
|
|
508
|
+
el.id = "item";
|
|
509
|
+
el.setAttribute("data-hf-authored-opacity", "0.5");
|
|
510
|
+
el.style.opacity = "0";
|
|
511
|
+
|
|
512
|
+
const opacity = restoreOpacity(
|
|
513
|
+
el,
|
|
514
|
+
'<html><body><template><li id="item" style="opacity: 0.3"></li></template></body></html>',
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
expect(opacity).toBe("0.5");
|
|
518
|
+
});
|
|
519
|
+
|
|
491
520
|
it("falls back to the parse-time stamp when no after-write HTML is given", () => {
|
|
492
521
|
const el = document.createElement("img");
|
|
493
522
|
el.setAttribute("data-hf-authored-opacity", "0.75");
|