@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.
Files changed (43) hide show
  1. package/dist/assets/{hyperframes-player-BdnQGwG5.js → hyperframes-player-Bx_Kw4Ey.js} +3 -3
  2. package/dist/assets/{index-C_n__Uh3.js → index-CJP92F4-.js} +1 -1
  3. package/dist/assets/{index-CX_4MYbk.js → index-DBKySIo7.js} +187 -187
  4. package/dist/assets/{index-DdpuHaGN.js → index-ORZXYB0k.js} +1 -1
  5. package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
  6. package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
  7. package/dist/index.d.ts +22 -15
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +452 -256
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/App.tsx +1 -0
  13. package/src/components/nle/useTimelineEditCallbacks.ts +8 -2
  14. package/src/hooks/timelineAudioGroupVolume.ts +21 -11
  15. package/src/hooks/timelineEditPermission.ts +47 -13
  16. package/src/hooks/timelineElementFxAttribute.ts +19 -13
  17. package/src/hooks/useClipboard.source.test.ts +340 -0
  18. package/src/hooks/useClipboard.ts +141 -62
  19. package/src/hooks/useElementPicker.test.ts +172 -0
  20. package/src/hooks/useElementPicker.ts +141 -93
  21. package/src/hooks/useFrameCapture.test.tsx +67 -0
  22. package/src/hooks/useFrameCapture.ts +18 -9
  23. package/src/hooks/usePersistentEditHistory.test.ts +2 -2
  24. package/src/hooks/usePersistentEditHistory.ts +2 -2
  25. package/src/hooks/useTimelineEditing.test.tsx +59 -0
  26. package/src/hooks/useTimelineEditing.ts +42 -33
  27. package/src/index.ts +1 -0
  28. package/src/player/components/Player.test.ts +5 -0
  29. package/src/player/components/Player.tsx +1 -0
  30. package/src/player/components/VideoThumbnail.test.tsx +13 -0
  31. package/src/player/components/VideoThumbnail.tsx +1 -1
  32. package/src/player/components/timelineCallbacks.ts +3 -2
  33. package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
  34. package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
  35. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  36. package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
  37. package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
  38. package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
  39. package/src/utils/authoredSource.ts +92 -0
  40. package/src/utils/gsapSoftReload.test.ts +29 -0
  41. package/src/utils/gsapSoftReload.ts +3 -7
  42. /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
  43. /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 % urls.length];
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 { processTimelineMessageRef, enrichMissingCompositionsRef, onIframeLoad } =
366
- useTimelineSyncCallbacks({
367
- iframeRef,
368
- probeIntervalRef,
369
- pendingSeekRef,
370
- isRefreshingRef,
371
- getAdapter,
372
- syncTimelineElements,
373
- setDuration,
374
- setCurrentTime,
375
- requestTimelineReady,
376
- setIsPlaying,
377
- attachIframeShortcutListeners,
378
- applyPreviewAudioState,
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 getAdapterRef = useRef(getAdapter);
456
- getAdapterRef.current = getAdapter;
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
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
494
+ cancelPendingLoad();
498
495
  };
499
496
  });
500
497
 
501
498
  const resetPlayer = useCallback(() => {
502
499
  stopRAFLoop();
503
500
  stopReverseLoop();
504
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
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 capture link and time display in sync
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
- window.removeEventListener("message", onMessage);
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 = () => window.removeEventListener("message", onMessage);
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
- window.removeEventListener("message", onMessage);
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 canvas = document.createElement("canvas");
56
- canvasesAtTimestamps.mockImplementation(async function* (timestamps: number[]) {
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 sourceDuration = Math.max(0, metadataDuration ?? request.sourceRangeDuration ?? 0);
142
- const sourceStart = Math.min(Math.max(0, request.sourceStart ?? 0), sourceDuration);
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, timestamps, signal, resources);
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");