@hyperframes/studio 0.8.78 → 0.8.80

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 (99) hide show
  1. package/dist/assets/hyperframes-player-CgnoxDoD.js +464 -0
  2. package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
  3. package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
  4. package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.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 +40 -16
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1918 -1408
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/App.tsx +6 -1
  13. package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
  14. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  15. package/src/components/editor/manualEdits.ts +1 -0
  16. package/src/components/editor/manualEditsParsing.ts +4 -0
  17. package/src/components/editor/useFxAudition.ts +3 -3
  18. package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
  19. package/src/contexts/DomEditContext.tsx +4 -0
  20. package/src/contexts/TimelineEditContext.tsx +2 -0
  21. package/src/hooks/liveLanes.ts +147 -0
  22. package/src/hooks/timelineAudioGroupVolume.ts +97 -53
  23. package/src/hooks/timelineEditPermission.ts +55 -13
  24. package/src/hooks/timelineEditingHelpers.test.ts +16 -0
  25. package/src/hooks/timelineEditingHelpers.ts +69 -43
  26. package/src/hooks/timelineElementFxAttribute.ts +92 -40
  27. package/src/hooks/useAppHotkeys.ts +1 -1
  28. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
  29. package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
  30. package/src/hooks/useBlockHandlers.test.tsx +65 -0
  31. package/src/hooks/useBlockHandlers.ts +7 -4
  32. package/src/hooks/useClipboard.source.test.ts +340 -0
  33. package/src/hooks/useClipboard.ts +141 -62
  34. package/src/hooks/useDomEditAttributeCommits.ts +3 -17
  35. package/src/hooks/useDomEditSession.ts +1 -0
  36. package/src/hooks/useEditHistoryActions.ts +4 -3
  37. package/src/hooks/useElementPicker.test.ts +172 -0
  38. package/src/hooks/useElementPicker.ts +141 -93
  39. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
  40. package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
  41. package/src/hooks/useFrameCapture.test.tsx +67 -0
  42. package/src/hooks/useFrameCapture.ts +18 -9
  43. package/src/hooks/usePersistentEditHistory.test.ts +2 -2
  44. package/src/hooks/usePersistentEditHistory.ts +2 -2
  45. package/src/hooks/useTimelineEditing.test.tsx +59 -0
  46. package/src/hooks/useTimelineEditing.ts +59 -47
  47. package/src/hooks/useTimelineGroupEditing.ts +2 -1
  48. package/src/hooks/useToast.ts +2 -1
  49. package/src/index.ts +1 -0
  50. package/src/player/components/ImageThumbnail.tsx +3 -12
  51. package/src/player/components/Player.test.ts +28 -0
  52. package/src/player/components/Player.tsx +5 -2
  53. package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
  54. package/src/player/components/TimelineAutomationLane.tsx +18 -13
  55. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
  56. package/src/player/components/TimelineClipFades.test.tsx +24 -3
  57. package/src/player/components/TimelineClipFades.tsx +5 -2
  58. package/src/player/components/TimelineFxButton.tsx +1 -1
  59. package/src/player/components/TimelineGroupHeader.tsx +1 -1
  60. package/src/player/components/TimelineGroupRow.test.tsx +0 -1
  61. package/src/player/components/TimelineGroupRow.tsx +17 -11
  62. package/src/player/components/TimelineTrackHeader.tsx +21 -6
  63. package/src/player/components/VideoThumbnail.test.tsx +13 -0
  64. package/src/player/components/VideoThumbnail.tsx +1 -1
  65. package/src/player/components/groupAutomationElement.ts +9 -1
  66. package/src/player/components/timelineCallbacks.ts +5 -2
  67. package/src/player/components/timelineClipDragPreview.test.ts +41 -0
  68. package/src/player/components/timelineClipDragPreview.ts +15 -24
  69. package/src/player/components/timelineEditing.test.ts +99 -1
  70. package/src/player/components/timelineEditing.ts +5 -8
  71. package/src/player/components/timelineGroupEditing.test.ts +27 -0
  72. package/src/player/components/timelineGroupEditing.ts +18 -15
  73. package/src/player/components/timelineStackingSync.ts +5 -5
  74. package/src/player/components/timelineZones.ts +2 -3
  75. package/src/player/components/useAutomationEdgeStretch.ts +4 -4
  76. package/src/player/components/useAutomationLaneGestures.ts +1 -1
  77. package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
  78. package/src/player/components/useAutomationLanes.ts +58 -74
  79. package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
  80. package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
  81. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  82. package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
  83. package/src/player/lib/automationStoreSync.ts +30 -3
  84. package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
  85. package/src/player/lib/thumbnailImageDecoder.ts +73 -0
  86. package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
  87. package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
  88. package/src/player/lib/timelineDOM.test.ts +28 -8
  89. package/src/player/lib/timelineElementHelpers.ts +3 -4
  90. package/src/player/lib/timelineGroupInfo.ts +23 -23
  91. package/src/utils/authoredSource.ts +92 -0
  92. package/src/utils/gsapSoftReload.test.ts +29 -0
  93. package/src/utils/gsapSoftReload.ts +3 -7
  94. package/src/utils/studioFileHistory.ts +36 -30
  95. package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
  96. package/src/player/components/automationGestureKeys.test.ts +0 -49
  97. package/src/player/components/automationGestureKeys.ts +0 -49
  98. /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
  99. /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
@@ -5,15 +5,15 @@
5
5
  * Reading lives in `automationLaneData`, shared with the row layout, which needs
6
6
  * the lane count to reserve height.
7
7
  *
8
- * Edits go to the *selected* element, because that is what the attribute commit
9
- * path targets. An unselected clip still draws its envelopes — they are just
10
- * read only, which is also what stops a stray drag from editing the wrong track.
8
+ * Edits save through the timeline's gated save, as an effect preset does, so a
9
+ * locked clip or group refuses them. Only the selected element is editable: an
10
+ * unselected one still draws its envelopes read only, which is also what stops a
11
+ * stray drag from editing the wrong track.
11
12
  */
12
13
 
13
- import { useCallback, useMemo, useRef } from "react";
14
+ import { useCallback, useMemo } from "react";
14
15
  import {
15
16
  HF_AUDIO_AUTOMATION_ATTR,
16
- serializeAutomation,
17
17
  type HfAutomation,
18
18
  type HfAutomationLane,
19
19
  } from "@hyperframes/core/audio-automation";
@@ -22,22 +22,25 @@ import {
22
22
  useDomEditActionsContextOptional,
23
23
  useDomEditSelectionContextOptional,
24
24
  } from "../../contexts/DomEditContext";
25
- import { resolveTimelineIdForSelection } from "../../utils/studioHelpers";
25
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
26
+ import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
26
27
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
27
28
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
28
29
  import type { AutomationSelection } from "../store/automationSelectionSlice";
30
+ import { automationAttrValue } from "../../components/editor/propertyPanelAutomation";
29
31
  import { elementAutomation, elementFxChain } from "./automationLaneData";
30
- import { createAutomationGestureKeys } from "./automationGestureKeys";
32
+ import { isGroupAutomationElement } from "./groupAutomationElement";
33
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
31
34
 
32
35
  export interface AutomationLaneBinding {
33
36
  automation: HfAutomation;
34
37
  /** One entry per lane, in draw order — each gets its own row. */
35
38
  lanes: HfAutomationLane[];
36
39
  chain: HfAudioFxChain | null;
37
- /** Continuous write while dragging; does not persist. */
38
- onPreview(next: HfAutomation): void;
40
+ /** Continuous write while dragging; does not persist. `ended`: a cancel put the start back. */
41
+ onPreview(next: HfAutomation, ended?: boolean): void;
39
42
  /** Gesture-end write; this is the one that persists and lands in undo. */
40
- onCommit(next: HfAutomation): void;
43
+ onCommit(next: HfAutomation): Promise<TimelineEditOutcome | void>;
41
44
  /**
42
45
  * Select this clip, which is what makes its lanes editable. A lane calls this
43
46
  * instead of writing when it is read-only — pressing the lane is the only
@@ -46,15 +49,6 @@ export interface AutomationLaneBinding {
46
49
  */
47
50
  onSelect(): void;
48
51
  readOnly: boolean;
49
- /**
50
- * The timeline clip `onCommit`/`onPreview` will ACTUALLY persist to, which is
51
- * whatever the dom-edit layer has selected — not necessarily the element this
52
- * binding was made for (see `onSelect` below). Null outside an edit session or
53
- * when the dom-edit selection maps to no clip. Resolved exactly the way
54
- * applyDomSelection resolves it, so in a settled selection it equals the bound
55
- * element's key; it lags only in the window a non-gesture caller can hit.
56
- */
57
- commitTargetKey: string | null;
58
52
  /** This element's active selection box, or null if none / it belongs to a
59
53
  * different element. */
60
54
  selection: AutomationSelection | null;
@@ -69,80 +63,69 @@ export interface UseAutomationLanesResult {
69
63
  bind(element: TimelineElement, isSelected: boolean): AutomationLaneBinding;
70
64
  }
71
65
 
66
+ const AUTOMATION_LABEL = "Edit automation";
67
+
68
+ /** A group's lanes save on the group, gated by its members; a clip's on the clip. */
69
+ function automationWriters(edit: TimelineEditCallbacks, element: TimelineElement) {
70
+ if (isGroupAutomationElement(element)) {
71
+ const { onSetAudioGroupAttributeLive: live, onSetAudioGroupAttributeQuiet: save } = edit;
72
+ if (!live || !save) return null;
73
+ return {
74
+ live: (value: string | null) => live(element.id, HF_AUDIO_AUTOMATION_ATTR, value),
75
+ revert: () => edit.onRevertAudioGroupAttributeLive?.(element.id, HF_AUDIO_AUTOMATION_ATTR),
76
+ save: (value: string | null) =>
77
+ save(element.id, HF_AUDIO_AUTOMATION_ATTR, value, AUTOMATION_LABEL),
78
+ };
79
+ }
80
+ const { onSetElementAttributeLive: live, onSetElementAttributeQuiet: save } = edit;
81
+ if (!live || !save) return null;
82
+ return {
83
+ live: (value: string | null) => live(element, HF_AUDIO_AUTOMATION_ATTR, value),
84
+ revert: () => edit.onRevertElementAttributeLive?.(element, HF_AUDIO_AUTOMATION_ATTR),
85
+ save: (value: string | null) =>
86
+ save(element, HF_AUDIO_AUTOMATION_ATTR, value, AUTOMATION_LABEL),
87
+ };
88
+ }
89
+
72
90
  export function useAutomationLanes(): UseAutomationLanesResult {
73
- // One per hook instance, held across renders: a gesture spans many commits and
74
- // they all have to record under the same key for undo to take the whole drag.
75
- const gestureKeys = useRef(createAutomationGestureKeys());
76
91
  // Optional: the player also runs outside Studio, where there is no edit
77
92
  // session. There the lanes render read-only, which is the right fallback.
78
93
  const domEdit = useDomEditActionsContextOptional();
79
- const domEditSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
80
- const elements = usePlayerStore((s) => s.elements);
94
+ const domEditSelectionRef = useDomEditSelectionContextOptional()?.domEditSelectionRef;
95
+ const timelineEdit = useTimelineEditContextOptional();
81
96
  const automationSelection = usePlayerStore((s) => s.automationSelection);
82
97
  const setAutomationSelection = usePlayerStore((s) => s.setAutomationSelection);
83
98
  const clearAutomationSelection = usePlayerStore((s) => s.clearAutomationSelection);
84
99
 
85
- // Read from the SAME render as the commit handlers below: both contexts update
86
- // in one commit, so a handler and this key can never describe different
87
- // moments. activeCompPath is not needed — resolveTimelineIdForSelection only
88
- // uses it as a fallback for a selection with no sourceFile of its own, and
89
- // DomEditSelection always carries one.
90
- const commitTargetKey = useMemo(
91
- () =>
92
- domEditSelection ? resolveTimelineIdForSelection(domEditSelection, elements, null) : null,
93
- [domEditSelection, elements],
94
- );
95
-
96
100
  const bind = useCallback(
97
101
  (element: TimelineElement, isSelected: boolean): AutomationLaneBinding => {
98
102
  const chain = elementFxChain(element);
99
103
  const automation = elementAutomation(element);
100
104
  const elementKey = getTimelineElementIdentity(element);
101
105
 
102
- const write = (next: HfAutomation, persist: boolean): void => {
103
- if (!domEdit || !isSelected) return;
104
- const value = next.lanes.length > 0 ? serializeAutomation(next) : "";
105
- // Every write of one gesture under one key, so undo takes back the whole
106
- // drag rather than the last fragment history happened to keep.
107
- const coalesce = persist ? gestureKeys.current.commit() : gestureKeys.current.live();
108
- // Quiet, not the refreshing commit: releasing a dragged point used to
109
- // reload the preview, which restarts every playing track — the same chop
110
- // the live write during the drag exists to avoid. Quiet still persists
111
- // and still resyncs the selection, so the next edit sees this one.
112
- if (persist) {
113
- void domEdit.handleDomAttributeQuietCommit(HF_AUDIO_AUTOMATION_ATTR, value, coalesce);
114
- }
115
- // Dragging a point writes live: no preview refresh, so the composition
116
- // does not reload and restart playback on every pixel.
117
- else {
118
- // Preview only, because a gesture writes on every pointermove: the
119
- // preview and the running audio follow the pointer, and the release
120
- // below is the one write that reaches the file and the undo stack.
121
- void domEdit.handleDomAttributeLiveCommit(
122
- HF_AUDIO_AUTOMATION_ATTR,
123
- value || null,
124
- undefined,
125
- {
126
- coalesce,
127
- previewOnly: true,
128
- },
129
- );
130
- }
131
- };
106
+ const writers = domEdit && isSelected ? automationWriters(timelineEdit, element) : null;
132
107
 
133
108
  return {
134
109
  automation,
135
110
  lanes: automation.lanes,
136
111
  chain,
137
- onPreview: (next) => write(next, false),
138
- onCommit: (next) => write(next, true),
139
- // Deliberately not awaited before an edit: the commit handlers close
140
- // over the selection as it was when they were built, so writing in the
141
- // same tick would land on whichever element was selected before.
142
- // Selecting is its own gesture; the lane goes live after it.
112
+ onPreview: (next, ended) => {
113
+ writers?.live(automationAttrValue(next) || null);
114
+ if (ended) writers?.revert();
115
+ },
116
+ onCommit: async (next) => {
117
+ if (!writers) return;
118
+ const outcome = await writers.save(automationAttrValue(next) || null);
119
+ // The FX panel reads the selection snapshot; a stale one makes its next
120
+ // edit start from the automation this one replaced.
121
+ const selection = domEditSelectionRef?.current;
122
+ if (selection) void domEdit?.refreshDomEditSelectionFromPreview(selection);
123
+ return outcome;
124
+ },
125
+ // Deliberately not awaited before an edit: the lane goes live when the
126
+ // selection lands and it re-renders as selected.
143
127
  onSelect: () => void domEdit?.handleTimelineElementSelect(element),
144
- readOnly: !domEdit || !isSelected,
145
- commitTargetKey: domEdit ? commitTargetKey : null,
128
+ readOnly: !writers,
146
129
  selection: automationSelection?.elementKey === elementKey ? automationSelection : null,
147
130
  // Not gated on `isSelected`, unlike the writes above. A selection is
148
131
  // ephemeral store state, and the drag that draws one on a read-only lane is
@@ -159,7 +142,8 @@ export function useAutomationLanes(): UseAutomationLanesResult {
159
142
  },
160
143
  [
161
144
  domEdit,
162
- commitTargetKey,
145
+ domEditSelectionRef,
146
+ timelineEdit,
163
147
  automationSelection,
164
148
  setAutomationSelection,
165
149
  clearAutomationSelection,
@@ -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>;
@@ -6,8 +6,9 @@
6
6
  * an envelope afterwards writes to the preview document and the source file, and the
7
7
  * store would go on holding the value it was born with until a reload.
8
8
  *
9
- * One reader, called from the two places a change lands: the resync every dom-edit
10
- * attribute commit already runs, and the soft restore an undo or redo applies. It
9
+ * One reader, called where a change lands: the resync every dom-edit attribute
10
+ * commit runs, and an undo or redo's soft restore (timeline saves record their own
11
+ * value through syncStoredElementAttribute below). It
11
12
  * reads the preview rather than being told, because those callers know a file
12
13
  * changed, not which attribute — and because three separate writers shipped without
13
14
  * remembering to sync, which is what a single sink prevents.
@@ -17,7 +18,7 @@ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
17
18
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
18
19
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
19
20
  import { groupInfoFor } from "./timelineGroupInfo";
20
- import { previewElementFinder } from "./timelineElementHelpers";
21
+ import { getTimelineElementIdentity, previewElementFinder } from "./timelineElementHelpers";
21
22
 
22
23
  /**
23
24
  * Re-read every element's automation and FX-chain attributes from the preview
@@ -72,3 +73,29 @@ export function syncStoredAutomationFromPreview(doc: Document | null | undefined
72
73
  return changed ? { elements } : {};
73
74
  });
74
75
  }
76
+
77
+ const STORED_FIELD: Record<string, "automation" | "fxChain"> = {
78
+ [HF_AUDIO_AUTOMATION_ATTR]: "automation",
79
+ [HF_AUDIO_FX_ATTR]: "fxChain",
80
+ };
81
+
82
+ /** Record a saved automation or FX-chain value on one element's stored copy. */
83
+ export function syncStoredElementAttribute(
84
+ target: TimelineElement,
85
+ attr: string,
86
+ value: string | null,
87
+ ): void {
88
+ const field = STORED_FIELD[attr];
89
+ if (!field) return;
90
+ const key = getTimelineElementIdentity(target);
91
+ const next = value ?? undefined;
92
+ usePlayerStore.setState((state) => {
93
+ let changed = false;
94
+ const elements = state.elements.map((element) => {
95
+ if (getTimelineElementIdentity(element) !== key || element[field] === next) return element;
96
+ changed = true;
97
+ return { ...element, [field]: next };
98
+ });
99
+ return changed ? { elements } : {};
100
+ });
101
+ }
@@ -0,0 +1,82 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { decodeImageThumbnail } from "./thumbnailImageDecoder";
4
+
5
+ class TestImage {
6
+ static instances: TestImage[] = [];
7
+ naturalWidth = 180;
8
+ naturalHeight = 135;
9
+ src = "";
10
+ onload: (() => void) | null = null;
11
+ onerror: (() => void) | null = null;
12
+ constructor() {
13
+ TestImage.instances.push(this);
14
+ }
15
+ }
16
+ beforeEach(() => {
17
+ TestImage.instances = [];
18
+ vi.stubGlobal("Image", TestImage);
19
+ });
20
+ afterEach(() => vi.unstubAllGlobals());
21
+ function latest() {
22
+ const image = TestImage.instances.at(-1);
23
+ if (!image) throw new Error("missing image");
24
+ return image;
25
+ }
26
+
27
+ describe("timeline image thumbnails", () => {
28
+ it("requests a bounded local JPEG and accounts for its actual decoded dimensions", async () => {
29
+ const result = decodeImageThumbnail(
30
+ "/api/projects/p/preview/photo.jpg?v=2",
31
+ new AbortController().signal,
32
+ );
33
+ expect(latest().src).toBe(
34
+ new URL("/api/projects/p/image-thumbnail/photo.jpg?v=2", location.href).href,
35
+ );
36
+ latest().onload?.();
37
+ expect(await result).toEqual({
38
+ value: {
39
+ kind: "image",
40
+ url: new URL("/api/projects/p/image-thumbnail/photo.jpg?v=2", location.href).href,
41
+ aspect: 4 / 3,
42
+ },
43
+ weight: 180 * 135 * 4,
44
+ });
45
+ expect(latest().src).toBe("");
46
+ });
47
+ it.each([
48
+ "https://remote.test/photo.jpg",
49
+ "/api/projects/p/preview/image.png",
50
+ "/api/projects/p/preview/image.gif",
51
+ "/api/projects/p/preview/image.webp",
52
+ "/api/projects/p/preview/image.svg",
53
+ "/other/photo.jpg",
54
+ ])("preserves %s", async (source) => {
55
+ const result = decodeImageThumbnail(source, new AbortController().signal);
56
+ expect(latest().src).toBe(source);
57
+ latest().onload?.();
58
+ expect((await result).value).toMatchObject({ url: source });
59
+ });
60
+ it("falls back for an older server and accounts for original dimensions", async () => {
61
+ const result = decodeImageThumbnail(
62
+ "/api/projects/p/preview/photo.jpg",
63
+ new AbortController().signal,
64
+ );
65
+ latest().onerror?.();
66
+ await Promise.resolve();
67
+ expect(latest().src).toBe("/api/projects/p/preview/photo.jpg");
68
+ latest().naturalWidth = 2560;
69
+ latest().naturalHeight = 1920;
70
+ latest().onload?.();
71
+ expect((await result).weight).toBe(2560 * 1920 * 4);
72
+ });
73
+ it("does not retry a cancelled request", async () => {
74
+ const controller = new AbortController();
75
+ const result = decodeImageThumbnail("/api/projects/p/preview/photo.jpg", controller.signal);
76
+ controller.abort();
77
+ await expect(result).rejects.toMatchObject({ name: "AbortError" });
78
+ expect(TestImage.instances).toHaveLength(1);
79
+ expect(latest().src).toBe("");
80
+ expect(latest().onload).toBeNull();
81
+ });
82
+ });
@@ -0,0 +1,73 @@
1
+ import type { ThumbnailLoadedResult } from "./thumbnailScheduler";
2
+ import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";
3
+
4
+ function thumbnailSource(source: string): string {
5
+ const url = new URL(source, window.location.href);
6
+ if (
7
+ url.origin !== window.location.origin ||
8
+ !/^\/api\/projects\/[^/]+\/preview\/.+\.jpe?g$/i.test(url.pathname)
9
+ )
10
+ return source;
11
+ url.pathname = url.pathname.replace(
12
+ /^(\/api\/projects\/[^/]+)\/preview\//,
13
+ "$1/image-thumbnail/",
14
+ );
15
+ return url.href;
16
+ }
17
+
18
+ function loadImage(source: string, signal: AbortSignal): Promise<HTMLImageElement> {
19
+ return new Promise((resolve, reject) => {
20
+ const image = new Image();
21
+ const cleanup = () => {
22
+ image.onload = null;
23
+ image.onerror = null;
24
+ signal.removeEventListener("abort", onAbort);
25
+ };
26
+ const onAbort = () => {
27
+ cleanup();
28
+ image.src = "";
29
+ reject(new DOMException("Aborted", "AbortError"));
30
+ };
31
+ if (signal.aborted) {
32
+ onAbort();
33
+ return;
34
+ }
35
+ image.onload = () => {
36
+ cleanup();
37
+ resolve(image);
38
+ };
39
+ image.onerror = () => {
40
+ cleanup();
41
+ if (/\.svg($|\?)/i.test(source)) resolve(image);
42
+ else {
43
+ image.src = "";
44
+ reject(new Error("Image thumbnail failed to load"));
45
+ }
46
+ };
47
+ signal.addEventListener("abort", onAbort, { once: true });
48
+ image.src = source;
49
+ });
50
+ }
51
+
52
+ export async function decodeImageThumbnail(
53
+ source: string,
54
+ signal: AbortSignal,
55
+ ): Promise<ThumbnailLoadedResult> {
56
+ let url = thumbnailSource(source);
57
+ let image: HTMLImageElement;
58
+ try {
59
+ image = await loadImage(url, signal);
60
+ } catch (error) {
61
+ if (signal.aborted || url === source) throw error;
62
+ url = source;
63
+ image = await loadImage(source, signal);
64
+ }
65
+ try {
66
+ signal.throwIfAborted();
67
+ const width = image.naturalWidth || TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth;
68
+ const height = image.naturalHeight || TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight;
69
+ return { value: { kind: "image", url, aspect: width / height }, weight: width * height * 4 };
70
+ } finally {
71
+ image.src = "";
72
+ }
73
+ }