@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.
- package/dist/assets/hyperframes-player-CgnoxDoD.js +464 -0
- package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
- package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
- package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.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 +40 -16
- package/dist/index.html +1 -1
- package/dist/index.js +1918 -1408
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -1
- package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +4 -0
- package/src/components/editor/useFxAudition.ts +3 -3
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/liveLanes.ts +147 -0
- package/src/hooks/timelineAudioGroupVolume.ts +97 -53
- package/src/hooks/timelineEditPermission.ts +55 -13
- package/src/hooks/timelineEditingHelpers.test.ts +16 -0
- package/src/hooks/timelineEditingHelpers.ts +69 -43
- package/src/hooks/timelineElementFxAttribute.ts +92 -40
- package/src/hooks/useAppHotkeys.ts +1 -1
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
- package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
- package/src/hooks/useBlockHandlers.test.tsx +65 -0
- package/src/hooks/useBlockHandlers.ts +7 -4
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useDomEditAttributeCommits.ts +3 -17
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useEditHistoryActions.ts +4 -3
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
- 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 +59 -47
- package/src/hooks/useTimelineGroupEditing.ts +2 -1
- package/src/hooks/useToast.ts +2 -1
- package/src/index.ts +1 -0
- package/src/player/components/ImageThumbnail.tsx +3 -12
- package/src/player/components/Player.test.ts +28 -0
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
- package/src/player/components/TimelineAutomationLane.tsx +18 -13
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
- package/src/player/components/TimelineClipFades.test.tsx +24 -3
- package/src/player/components/TimelineClipFades.tsx +5 -2
- package/src/player/components/TimelineFxButton.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +1 -1
- package/src/player/components/TimelineGroupRow.test.tsx +0 -1
- package/src/player/components/TimelineGroupRow.tsx +17 -11
- package/src/player/components/TimelineTrackHeader.tsx +21 -6
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/groupAutomationElement.ts +9 -1
- package/src/player/components/timelineCallbacks.ts +5 -2
- package/src/player/components/timelineClipDragPreview.test.ts +41 -0
- package/src/player/components/timelineClipDragPreview.ts +15 -24
- package/src/player/components/timelineEditing.test.ts +99 -1
- package/src/player/components/timelineEditing.ts +5 -8
- package/src/player/components/timelineGroupEditing.test.ts +27 -0
- package/src/player/components/timelineGroupEditing.ts +18 -15
- package/src/player/components/timelineStackingSync.ts +5 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/player/components/useAutomationEdgeStretch.ts +4 -4
- package/src/player/components/useAutomationLaneGestures.ts +1 -1
- package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
- package/src/player/components/useAutomationLanes.ts +58 -74
- 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/automationStoreSync.ts +30 -3
- package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
- package/src/player/lib/thumbnailImageDecoder.ts +73 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/player/lib/timelineDOM.test.ts +28 -8
- package/src/player/lib/timelineElementHelpers.ts +3 -4
- package/src/player/lib/timelineGroupInfo.ts +23 -23
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- package/src/utils/studioFileHistory.ts +36 -30
- package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
- package/src/player/components/automationGestureKeys.test.ts +0 -49
- package/src/player/components/automationGestureKeys.ts +0 -49
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /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
|
|
9
|
-
*
|
|
10
|
-
* read only, which is also what stops a
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
80
|
-
const
|
|
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
|
|
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) =>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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: !
|
|
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
|
-
|
|
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 {
|
|
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>;
|
|
@@ -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
|
|
10
|
-
*
|
|
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
|
+
}
|