@hyperframes/studio 0.8.82 → 0.8.84
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-zGIbZhLJ.js → hyperframes-player-Dd74fv7B.js} +3 -3
- package/dist/assets/{index-DStmfnpZ.js → index-D9dmtDap.js} +1 -1
- package/dist/assets/{index-BiLCTunu.js → index-DFcIBFM8.js} +1 -1
- package/dist/assets/{index-DSazIt7R.js → index-gjbSm-vt.js} +193 -193
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-7VDQIGOU.js +1871 -0
- package/dist/chunk-7VDQIGOU.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +669 -330
- package/dist/index.html +1 -1
- package/dist/index.js +20512 -23172
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +234 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +135 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +7 -43
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/useDomEditCommits.ts +1 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +36 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -0
- package/src/hooks/useElementLifecycleOps.ts +1 -1
- package/src/hooks/useInlineTextEdit.test.tsx +39 -0
- package/src/hooks/useInlineTextEdit.ts +5 -9
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/index.ts +45 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/player/hooks/timelinePlayerSync.test.ts +1 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +41 -7
- package/src/player/lib/timelineElementHelpers.ts +7 -7
- package/src/playerEntry.ts +1 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -11,86 +11,108 @@ afterEach(() => {
|
|
|
11
11
|
usePlayerStore.getState().reset();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
const element: TimelineElement = {
|
|
15
|
+
id: "clip-1",
|
|
16
|
+
tag: "div",
|
|
17
|
+
start: 0,
|
|
18
|
+
duration: 2,
|
|
19
|
+
track: 0,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function mountGesture(kind: "drag" | "resize") {
|
|
23
|
+
const drag: DraggedClipState = {
|
|
24
|
+
pointerId: 0,
|
|
25
|
+
element,
|
|
26
|
+
originClientX: 0,
|
|
27
|
+
originClientY: 0,
|
|
28
|
+
originScrollLeft: 0,
|
|
29
|
+
originScrollTop: 0,
|
|
30
|
+
pointerClientX: 0,
|
|
31
|
+
pointerClientY: 0,
|
|
32
|
+
pointerOffsetX: 0,
|
|
33
|
+
pointerOffsetY: 0,
|
|
34
|
+
previewStart: 1,
|
|
35
|
+
previewTrack: 0,
|
|
36
|
+
desiredTrack: 0,
|
|
37
|
+
insertRow: null,
|
|
38
|
+
snapTime: null,
|
|
39
|
+
snapType: null,
|
|
40
|
+
started: true,
|
|
41
|
+
};
|
|
42
|
+
const resize: ResizingClipState = {
|
|
43
|
+
pointerId: 0,
|
|
44
|
+
element,
|
|
45
|
+
edge: "end",
|
|
46
|
+
originClientX: 0,
|
|
47
|
+
previewStart: 0,
|
|
48
|
+
previewDuration: 3,
|
|
49
|
+
started: true,
|
|
50
|
+
};
|
|
51
|
+
const draggedClipRef = { current: kind === "drag" ? drag : null };
|
|
52
|
+
const resizingClipRef = { current: kind === "resize" ? resize : null };
|
|
53
|
+
const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
|
|
54
|
+
draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
|
|
55
|
+
};
|
|
56
|
+
const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
|
|
57
|
+
...previous,
|
|
58
|
+
previewStart: 3,
|
|
59
|
+
}));
|
|
60
|
+
const onMoveElement = vi.fn();
|
|
61
|
+
const onResizeElement = vi.fn();
|
|
62
|
+
const updateElement = vi.fn();
|
|
63
|
+
const stopAutoScroll = vi.fn();
|
|
64
|
+
const cancelGestureRef = { current: () => false };
|
|
65
|
+
const dispose = mountTimelineClipDragGestureLifecycle({
|
|
66
|
+
lifecycleRef: { current: { kind, phase: "active", pointerId: null, sessionEpoch: 0 } },
|
|
67
|
+
sessionEpochRef: { current: 0 },
|
|
68
|
+
cancelGestureRef,
|
|
69
|
+
scrollRef: { current: null },
|
|
70
|
+
draggedClipRef,
|
|
71
|
+
resizingClipRef,
|
|
72
|
+
blockedClipRef: { current: null },
|
|
73
|
+
groupResizeRef: { current: null },
|
|
74
|
+
suppressClickRef: { current: false },
|
|
75
|
+
gestureSelectedKeysRef: { current: new Set() },
|
|
76
|
+
elementsRef: { current: [element] },
|
|
77
|
+
trackOrderRef: { current: [0] },
|
|
78
|
+
setDraggedClipState: setDraggedClip,
|
|
79
|
+
setResizingClipState: () => {},
|
|
80
|
+
setShowPopover: () => {},
|
|
81
|
+
setRangeSelectionRef: { current: null },
|
|
82
|
+
applyResizePointerRef: { current: () => {} },
|
|
83
|
+
syncClipDragAutoScrollRef: { current: () => {} },
|
|
84
|
+
stopClipDragAutoScrollRef: { current: stopAutoScroll },
|
|
85
|
+
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
|
|
86
|
+
publishDraggedClip: setDraggedClip,
|
|
87
|
+
updateElement,
|
|
88
|
+
onMoveElementRef: { current: onMoveElement },
|
|
89
|
+
onMoveElementsRef: { current: undefined },
|
|
90
|
+
onResizeElementRef: { current: onResizeElement },
|
|
91
|
+
onResizeElementsRef: { current: undefined },
|
|
92
|
+
onBlockedEditAttemptRef: { current: undefined },
|
|
93
|
+
readZIndexRef: { current: undefined },
|
|
94
|
+
onStackingPatchesRef: { current: undefined },
|
|
95
|
+
refreshAfterLaneMoveRef: { current: undefined },
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
draggedClipRef,
|
|
99
|
+
resizingClipRef,
|
|
100
|
+
updateDraggedClipPreview,
|
|
101
|
+
onMoveElement,
|
|
102
|
+
onResizeElement,
|
|
103
|
+
updateElement,
|
|
104
|
+
stopAutoScroll,
|
|
105
|
+
cancelGestureRef,
|
|
106
|
+
dispose,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const release = (clientX: number, clientY: number) =>
|
|
111
|
+
window.dispatchEvent(new MouseEvent("pointerup", { clientX, clientY }));
|
|
112
|
+
|
|
14
113
|
describe("timeline clip drag gesture lifecycle", () => {
|
|
15
114
|
it("finishes a drag after virtualization unmounts its source row", () => {
|
|
16
|
-
const
|
|
17
|
-
id: "clip-1",
|
|
18
|
-
tag: "div",
|
|
19
|
-
start: 0,
|
|
20
|
-
duration: 2,
|
|
21
|
-
track: 0,
|
|
22
|
-
};
|
|
23
|
-
const drag: DraggedClipState = {
|
|
24
|
-
pointerId: 0,
|
|
25
|
-
element,
|
|
26
|
-
originClientX: 0,
|
|
27
|
-
originClientY: 0,
|
|
28
|
-
originScrollLeft: 0,
|
|
29
|
-
originScrollTop: 0,
|
|
30
|
-
pointerClientX: 0,
|
|
31
|
-
pointerClientY: 0,
|
|
32
|
-
pointerOffsetX: 0,
|
|
33
|
-
pointerOffsetY: 0,
|
|
34
|
-
previewStart: 1,
|
|
35
|
-
previewTrack: 0,
|
|
36
|
-
desiredTrack: 0,
|
|
37
|
-
insertRow: null,
|
|
38
|
-
snapTime: null,
|
|
39
|
-
snapType: null,
|
|
40
|
-
started: true,
|
|
41
|
-
};
|
|
42
|
-
const draggedClipRef = { current: drag as DraggedClipState | null };
|
|
43
|
-
const resizingClipRef = { current: null as ResizingClipState | null };
|
|
44
|
-
const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
|
|
45
|
-
draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
|
|
46
|
-
};
|
|
47
|
-
const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
|
|
48
|
-
...previous,
|
|
49
|
-
previewStart: 3,
|
|
50
|
-
}));
|
|
51
|
-
const onMoveElement = vi.fn();
|
|
52
|
-
const stopAutoScroll = vi.fn();
|
|
53
|
-
const cancelGestureRef = { current: () => false };
|
|
54
|
-
const dispose = mountTimelineClipDragGestureLifecycle({
|
|
55
|
-
lifecycleRef: {
|
|
56
|
-
current: {
|
|
57
|
-
kind: "drag",
|
|
58
|
-
phase: "active",
|
|
59
|
-
pointerId: null,
|
|
60
|
-
sessionEpoch: 0,
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
sessionEpochRef: { current: 0 },
|
|
64
|
-
cancelGestureRef,
|
|
65
|
-
scrollRef: { current: null },
|
|
66
|
-
draggedClipRef,
|
|
67
|
-
resizingClipRef,
|
|
68
|
-
blockedClipRef: { current: null },
|
|
69
|
-
groupResizeRef: { current: null },
|
|
70
|
-
suppressClickRef: { current: false },
|
|
71
|
-
gestureSelectedKeysRef: { current: new Set() },
|
|
72
|
-
elementsRef: { current: [element] },
|
|
73
|
-
trackOrderRef: { current: [0] },
|
|
74
|
-
setDraggedClipState: setDraggedClip,
|
|
75
|
-
setResizingClipState: () => {},
|
|
76
|
-
setShowPopover: () => {},
|
|
77
|
-
setRangeSelectionRef: { current: null },
|
|
78
|
-
applyResizePointerRef: { current: () => {} },
|
|
79
|
-
syncClipDragAutoScrollRef: { current: () => {} },
|
|
80
|
-
stopClipDragAutoScrollRef: { current: stopAutoScroll },
|
|
81
|
-
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
|
|
82
|
-
publishDraggedClip: setDraggedClip,
|
|
83
|
-
updateElement: vi.fn(),
|
|
84
|
-
onMoveElementRef: { current: onMoveElement },
|
|
85
|
-
onMoveElementsRef: { current: undefined },
|
|
86
|
-
onResizeElementRef: { current: undefined },
|
|
87
|
-
onResizeElementsRef: { current: undefined },
|
|
88
|
-
onBlockedEditAttemptRef: { current: undefined },
|
|
89
|
-
readZIndexRef: { current: undefined },
|
|
90
|
-
onStackingPatchesRef: { current: undefined },
|
|
91
|
-
refreshAfterLaneMoveRef: { current: undefined },
|
|
92
|
-
});
|
|
93
|
-
|
|
115
|
+
const g = mountGesture("drag");
|
|
94
116
|
const sourceRow = document.createElement("div");
|
|
95
117
|
sourceRow.dataset.timelineRow = "";
|
|
96
118
|
sourceRow.append(document.createElement("div"));
|
|
@@ -98,15 +120,49 @@ describe("timeline clip drag gesture lifecycle", () => {
|
|
|
98
120
|
sourceRow.remove();
|
|
99
121
|
|
|
100
122
|
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
|
|
101
|
-
expect(updateDraggedClipPreview).toHaveBeenCalledTimes(1);
|
|
102
|
-
expect(draggedClipRef.current?.previewStart).toBe(3);
|
|
123
|
+
expect(g.updateDraggedClipPreview).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(g.draggedClipRef.current?.previewStart).toBe(3);
|
|
103
125
|
|
|
104
126
|
window.dispatchEvent(new MouseEvent("pointerup"));
|
|
105
|
-
expect(onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
|
|
106
|
-
expect(draggedClipRef.current).toBeNull();
|
|
107
|
-
expect(stopAutoScroll).toHaveBeenCalledTimes(1);
|
|
127
|
+
expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
|
|
128
|
+
expect(g.draggedClipRef.current).toBeNull();
|
|
129
|
+
expect(g.stopAutoScroll).toHaveBeenCalledTimes(1);
|
|
130
|
+
|
|
131
|
+
g.dispose();
|
|
132
|
+
expect(g.cancelGestureRef.current()).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it.each([
|
|
136
|
+
["above the window", 20, -40],
|
|
137
|
+
["past the window's right edge", window.innerWidth + 40, 10],
|
|
138
|
+
["on the window's right edge", window.innerWidth, 10],
|
|
139
|
+
["on the window's bottom edge", 20, window.innerHeight],
|
|
140
|
+
])("cancels a drag released %s and leaves the clip where it was", (_, clientX, clientY) => {
|
|
141
|
+
const g = mountGesture("drag");
|
|
142
|
+
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
|
|
143
|
+
release(clientX, clientY);
|
|
144
|
+
expect(g.onMoveElement).not.toHaveBeenCalled();
|
|
145
|
+
expect(g.updateElement).not.toHaveBeenCalled();
|
|
146
|
+
expect(g.draggedClipRef.current).toBeNull();
|
|
147
|
+
// The cancel ended the gesture, so a later release has nothing to commit.
|
|
148
|
+
release(20, 10);
|
|
149
|
+
expect(g.onMoveElement).not.toHaveBeenCalled();
|
|
150
|
+
g.dispose();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("cancels a trim released outside the window", () => {
|
|
154
|
+
const g = mountGesture("resize");
|
|
155
|
+
release(20, -40);
|
|
156
|
+
expect(g.onResizeElement).not.toHaveBeenCalled();
|
|
157
|
+
expect(g.updateElement).not.toHaveBeenCalled();
|
|
158
|
+
expect(g.resizingClipRef.current).toBeNull();
|
|
159
|
+
g.dispose();
|
|
160
|
+
});
|
|
108
161
|
|
|
109
|
-
|
|
110
|
-
|
|
162
|
+
it("still commits a drag released on the window's last pixel", () => {
|
|
163
|
+
const g = mountGesture("drag");
|
|
164
|
+
release(window.innerWidth - 1, window.innerHeight - 1);
|
|
165
|
+
expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 1, track: 0 });
|
|
166
|
+
g.dispose();
|
|
111
167
|
});
|
|
112
168
|
});
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from "./timelineClipDragTypes";
|
|
9
9
|
import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
|
|
10
10
|
import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
|
|
11
|
+
import { releasedOutsideWindow } from "./timelinePointerRelease";
|
|
11
12
|
import {
|
|
12
13
|
beginTimelineOptimisticGesture,
|
|
13
14
|
rollbackLatestTimelineOptimisticGesture,
|
|
@@ -349,6 +350,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
349
350
|
};
|
|
350
351
|
|
|
351
352
|
const handleWindowPointerUp = (event: PointerEvent) => {
|
|
353
|
+
if (releasedOutsideWindow(event)) return handleWindowPointerCancel(event);
|
|
352
354
|
const claimed = claimActiveGesture(event);
|
|
353
355
|
if (claimed === "ignored") return;
|
|
354
356
|
if (claimed) {
|
|
@@ -71,6 +71,8 @@ export function useTimelineProviderState({
|
|
|
71
71
|
canPasteClip,
|
|
72
72
|
theme: themeOverrides,
|
|
73
73
|
sessionEpoch = 0,
|
|
74
|
+
previewIframeRef,
|
|
75
|
+
onZIndexReorder,
|
|
74
76
|
}: TimelineProps = {}): TimelineContextValue {
|
|
75
77
|
const {
|
|
76
78
|
onMoveElement,
|
|
@@ -184,6 +186,8 @@ export function useTimelineProviderState({
|
|
|
184
186
|
});
|
|
185
187
|
const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
|
|
186
188
|
expandedElementsRef: timelineElementsRef,
|
|
189
|
+
previewIframeRef,
|
|
190
|
+
onZIndexReorder,
|
|
187
191
|
});
|
|
188
192
|
const {
|
|
189
193
|
draggedClip,
|
|
@@ -73,4 +73,50 @@ describe("useTimelineStackingSync", () => {
|
|
|
73
73
|
mocks.actions = null;
|
|
74
74
|
iframe.remove();
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
it("uses a host's iframe and commit over Studio's own", async () => {
|
|
78
|
+
const studioCommit = vi.fn().mockResolvedValue(undefined);
|
|
79
|
+
mocks.actions = {
|
|
80
|
+
previewIframeRef: { current: null },
|
|
81
|
+
handleDomZIndexReorderCommit: studioCommit,
|
|
82
|
+
};
|
|
83
|
+
const iframe = document.createElement("iframe");
|
|
84
|
+
document.body.appendChild(iframe);
|
|
85
|
+
const node = iframe.contentDocument!.createElement("div");
|
|
86
|
+
node.id = "a";
|
|
87
|
+
iframe.contentDocument!.body.appendChild(node);
|
|
88
|
+
const hostCommit = vi.fn().mockResolvedValue(undefined);
|
|
89
|
+
const element: TimelineElement = {
|
|
90
|
+
id: "a",
|
|
91
|
+
key: "a",
|
|
92
|
+
tag: "div",
|
|
93
|
+
start: 0,
|
|
94
|
+
duration: 2,
|
|
95
|
+
track: 0,
|
|
96
|
+
};
|
|
97
|
+
let sync: ReturnType<typeof useTimelineStackingSync> | null = null;
|
|
98
|
+
function Harness() {
|
|
99
|
+
sync = useTimelineStackingSync({
|
|
100
|
+
expandedElementsRef: { current: [element] },
|
|
101
|
+
previewIframeRef: { current: iframe },
|
|
102
|
+
onZIndexReorder: hostCommit,
|
|
103
|
+
});
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const root = mountReactHarness(<Harness />);
|
|
107
|
+
|
|
108
|
+
await act(async () => {
|
|
109
|
+
await sync!.applyStackingPatches([{ key: "a", zIndex: 3 }]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(sync!.zSyncEnabled).toBe(true);
|
|
113
|
+
expect(hostCommit).toHaveBeenCalledWith(
|
|
114
|
+
[expect.objectContaining({ element: node, zIndex: 3 })],
|
|
115
|
+
undefined,
|
|
116
|
+
);
|
|
117
|
+
expect(studioCommit).not.toHaveBeenCalled();
|
|
118
|
+
act(() => root.unmount());
|
|
119
|
+
mocks.actions = null;
|
|
120
|
+
iframe.remove();
|
|
121
|
+
});
|
|
76
122
|
});
|
|
@@ -5,23 +5,30 @@ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
|
5
5
|
import { findElementForSelection } from "../../components/editor/domEditingElement";
|
|
6
6
|
import { readEffectiveZIndex } from "../../components/editor/canvasContextMenuZOrder";
|
|
7
7
|
import type { StackingPatch } from "./timelineStackingSync";
|
|
8
|
+
import type { TimelineZIndexReorderCommit } from "../../hooks/useTimelineEditingTypes";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
/** With both set, a lane move restacks the preview's z-index to match the rows; each overrides Studio's own. */
|
|
11
|
+
export interface TimelineStackingSyncProps {
|
|
12
|
+
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
|
|
13
|
+
onZIndexReorder?: TimelineZIndexReorderCommit;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface UseTimelineStackingSyncInput extends TimelineStackingSyncProps {
|
|
10
17
|
expandedElementsRef: RefObject<TimelineElement[]>;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
|
-
// Lane ↔ stacking
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStackingSyncInput) {
|
|
20
|
+
// Lane ↔ stacking sync: the two deps commitDraggedClipMove takes so a lane-change drag also patches
|
|
21
|
+
// z-index, through the same iframe + z persist path the canvas menu and LayersPanel use. Either missing ⇒ no-op.
|
|
22
|
+
export function useTimelineStackingSync({
|
|
23
|
+
expandedElementsRef,
|
|
24
|
+
previewIframeRef,
|
|
25
|
+
onZIndexReorder,
|
|
26
|
+
}: UseTimelineStackingSyncInput) {
|
|
21
27
|
const domEditActions = useDomEditActionsContextOptional();
|
|
22
28
|
const shell = useStudioShellContextOptional();
|
|
23
|
-
const zSyncPreviewIframeRef = domEditActions?.previewIframeRef ?? null;
|
|
24
|
-
const handleDomZIndexReorderCommit =
|
|
29
|
+
const zSyncPreviewIframeRef = previewIframeRef ?? domEditActions?.previewIframeRef ?? null;
|
|
30
|
+
const handleDomZIndexReorderCommit =
|
|
31
|
+
onZIndexReorder ?? domEditActions?.handleDomZIndexReorderCommit;
|
|
25
32
|
const zSyncActiveCompPath = shell?.activeCompPath ?? null;
|
|
26
33
|
|
|
27
34
|
// Resolve a TimelineElement to its live iframe HTMLElement via the same
|
|
@@ -13,6 +13,7 @@ describe("timelineElementsChanged", () => {
|
|
|
13
13
|
["audio group hidden", { audioGroupHidden: true }],
|
|
14
14
|
["fade in", { fadeIn: 1 }],
|
|
15
15
|
["fade out", { fadeOut: 1 }],
|
|
16
|
+
["source (re-pointed at its preview copy)", { src: "clip1.mp4?hf-proxy=h264" }],
|
|
16
17
|
])("sees a clip whose %s changed with no timing change", (_name, change) => {
|
|
17
18
|
expect(timelineElementsChanged([clip], [{ ...clip, ...change }])).toBe(true);
|
|
18
19
|
expect(timelineElementsChanged([{ ...clip, ...change }], [{ ...clip, ...change }])).toBe(false);
|
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
|
10
10
|
import { computeResizePreview } from "../components/timelineClipDragPreview";
|
|
11
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
MAX_PLAYBACK_RATE,
|
|
14
|
+
MIN_PLAYBACK_RATE,
|
|
15
|
+
readMediaOffsetSeconds,
|
|
16
|
+
} from "@hyperframes/parsers/media-duration";
|
|
13
17
|
|
|
14
18
|
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
|
|
15
19
|
return { id, tag: "img", start: 0, duration: 5, track: 0, ...extra };
|
|
@@ -269,19 +273,49 @@ describe("group info cache", () => {
|
|
|
269
273
|
|
|
270
274
|
describe("parseTimelineFromDOM — canonical playback rate", () => {
|
|
271
275
|
it.each([
|
|
272
|
-
["
|
|
273
|
-
["
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
["8", 8],
|
|
277
|
+
["10", MAX_PLAYBACK_RATE],
|
|
278
|
+
["50", MAX_PLAYBACK_RATE],
|
|
279
|
+
["0.01", MIN_PLAYBACK_RATE],
|
|
280
|
+
])(
|
|
281
|
+
"clamps authored rate %s to %s, as playback does, for trim and split math",
|
|
282
|
+
(authored, expected) => {
|
|
283
|
+
const doc = makeDoc(`
|
|
276
284
|
<div data-composition-id="root">
|
|
277
285
|
<div id="nested" class="clip" data-composition-src="scene.html"
|
|
278
286
|
data-start="0" data-duration="5" data-playback-rate="${authored}"></div>
|
|
279
287
|
</div>
|
|
280
288
|
`);
|
|
281
289
|
|
|
282
|
-
|
|
290
|
+
const nested = parseTimelineFromDOM(doc, 10).find((entry) => entry.domId === "nested");
|
|
291
|
+
|
|
292
|
+
expect(nested?.playbackRate).toBe(expected);
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
});
|
|
283
296
|
|
|
284
|
-
|
|
297
|
+
describe("parseTimelineFromDOM — head trim at an authored speed above 5x", () => {
|
|
298
|
+
it("moves the in-point by the speed playback uses", () => {
|
|
299
|
+
const doc = makeDoc(`
|
|
300
|
+
<div data-composition-id="root">
|
|
301
|
+
<video id="v" class="clip" data-start="2" data-duration="4" data-media-start="1"
|
|
302
|
+
data-playback-rate="8"></video>
|
|
303
|
+
</div>
|
|
304
|
+
`);
|
|
305
|
+
const element = parseTimelineFromDOM(doc, 10).find((e) => e.domId === "v")!;
|
|
306
|
+
const preview = computeResizePreview(
|
|
307
|
+
{
|
|
308
|
+
element,
|
|
309
|
+
edge: "start",
|
|
310
|
+
originClientX: 0,
|
|
311
|
+
previewStart: 2,
|
|
312
|
+
previewDuration: 4,
|
|
313
|
+
started: true,
|
|
314
|
+
},
|
|
315
|
+
100,
|
|
316
|
+
{ scroll: null, pps: 100, buildSnapTargets: () => [] },
|
|
317
|
+
);
|
|
318
|
+
expect([preview.previewStart, preview.previewPlaybackStart]).toEqual([3, 9]);
|
|
285
319
|
});
|
|
286
320
|
});
|
|
287
321
|
|
|
@@ -13,7 +13,11 @@ import { isFinitePositive } from "./playbackAdapter";
|
|
|
13
13
|
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
|
|
14
14
|
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
15
15
|
import { readElementFades } from "@hyperframes/core/audio-fade";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
type AttrReader,
|
|
18
|
+
clampPlaybackRate,
|
|
19
|
+
readMediaOffsetSeconds,
|
|
20
|
+
} from "@hyperframes/parsers/media-duration";
|
|
17
21
|
|
|
18
22
|
// ---------------------------------------------------------------------------
|
|
19
23
|
// Layer-reveal lift transparency
|
|
@@ -79,10 +83,6 @@ function readDurationAttribute(el: Element | null | undefined): number {
|
|
|
79
83
|
return isFinitePositive(duration) ? duration : 0;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
function normalizePlaybackRate(raw: number): number {
|
|
83
|
-
return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
86
|
export function isTimelineIgnoredElement(el: Element): boolean {
|
|
87
87
|
// An `<hf-audio-group>` is a mixer bus with no timing of its own, drawn as a group row, never a clip.
|
|
88
88
|
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return true;
|
|
@@ -199,7 +199,7 @@ function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element):
|
|
|
199
199
|
|
|
200
200
|
const authoredPlaybackRate = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
|
|
201
201
|
if (Number.isFinite(authoredPlaybackRate) && authoredPlaybackRate > 0) {
|
|
202
|
-
entry.playbackRate =
|
|
202
|
+
entry.playbackRate = clampPlaybackRate(authoredPlaybackRate);
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
@@ -262,7 +262,7 @@ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Elemen
|
|
|
262
262
|
|
|
263
263
|
const playbackRate = mediaEl.defaultPlaybackRate;
|
|
264
264
|
if (entry.playbackRate == null && Number.isFinite(playbackRate) && playbackRate > 0) {
|
|
265
|
-
entry.playbackRate =
|
|
265
|
+
entry.playbackRate = clampPlaybackRate(playbackRate);
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Player } from "./player/components/Player";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Mounts the timeline by package name with no Studio shell, as a host app does.
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { expect, it, vi } from "vitest";
|
|
5
|
+
import { Timeline, usePlayerStore, type TimelineZIndexReorderCommit } from "@hyperframes/studio";
|
|
6
|
+
import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
|
|
7
|
+
|
|
8
|
+
installReactActEnvironment();
|
|
9
|
+
// happy-dom has no layout; the timeline only renders clips inside a sized viewport.
|
|
10
|
+
for (const [prop, px] of [
|
|
11
|
+
["clientWidth", 1000],
|
|
12
|
+
["clientHeight", 400],
|
|
13
|
+
["offsetWidth", 1000],
|
|
14
|
+
["offsetHeight", 400],
|
|
15
|
+
] as const) {
|
|
16
|
+
vi.spyOn(HTMLElement.prototype, prop, "get").mockReturnValue(px);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const settle = () => act(async () => new Promise((r) => setTimeout(r, 50)));
|
|
20
|
+
const pointer = (type: string, clientY: number) =>
|
|
21
|
+
new PointerEvent(type, { bubbles: true, button: 0, pointerId: 1, clientX: 150, clientY });
|
|
22
|
+
|
|
23
|
+
it("restacks the host's preview when a lane move puts a clip below another", async () => {
|
|
24
|
+
const iframe = document.createElement("iframe");
|
|
25
|
+
document.body.appendChild(iframe);
|
|
26
|
+
const doc = iframe.contentDocument!;
|
|
27
|
+
// No authored z: DOM order paints tag over title, which matches the rows until the move.
|
|
28
|
+
doc.body.innerHTML = '<div id="title"></div><div id="tag"></div><div id="note"></div>';
|
|
29
|
+
const clip = (id: string, start: number, duration: number, track: number) =>
|
|
30
|
+
({ id, key: id, domId: id, tag: "div", start, duration, track }) as const;
|
|
31
|
+
usePlayerStore.setState({
|
|
32
|
+
duration: 10,
|
|
33
|
+
timelineReady: true,
|
|
34
|
+
elements: [clip("title", 0, 4, 1), clip("tag", 0, 4, 0), clip("note", 6, 2, 2)],
|
|
35
|
+
});
|
|
36
|
+
const commit = vi.fn<TimelineZIndexReorderCommit>(async (entries) => {
|
|
37
|
+
for (const e of entries) e.element.style.zIndex = String(e.zIndex);
|
|
38
|
+
});
|
|
39
|
+
const root = mountReactHarness(
|
|
40
|
+
<Timeline
|
|
41
|
+
previewIframeRef={{ current: iframe }}
|
|
42
|
+
onZIndexReorder={commit}
|
|
43
|
+
onMoveElement={async () => {}}
|
|
44
|
+
onMoveElements={async () => {}}
|
|
45
|
+
/>,
|
|
46
|
+
);
|
|
47
|
+
await settle();
|
|
48
|
+
const tagClip = document.querySelector<HTMLElement>('.timeline-clip[aria-label^="tag,"]')!;
|
|
49
|
+
vi.spyOn(tagClip, "getBoundingClientRect").mockReturnValue(new DOMRect(80, 77, 360, 42));
|
|
50
|
+
|
|
51
|
+
// Drag tag from the top row to the bottom row, clear of note in time.
|
|
52
|
+
act(() => void tagClip.dispatchEvent(pointer("pointerdown", 98)));
|
|
53
|
+
act(() => {
|
|
54
|
+
window.dispatchEvent(pointer("pointermove", 140));
|
|
55
|
+
window.dispatchEvent(pointer("pointermove", 194));
|
|
56
|
+
});
|
|
57
|
+
await act(async () => void window.dispatchEvent(pointer("pointerup", 194)));
|
|
58
|
+
await settle();
|
|
59
|
+
|
|
60
|
+
expect(usePlayerStore.getState().elements.find((e) => e.id === "tag")?.track).toBe(2);
|
|
61
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(commit.mock.calls[0]![0].every((e) => e.element.ownerDocument === doc)).toBe(true);
|
|
63
|
+
const z = (id: string) => Number(doc.getElementById(id)!.style.zIndex || 0);
|
|
64
|
+
expect(z("title")).toBeGreaterThan(z("tag"));
|
|
65
|
+
await act(async () => root.unmount());
|
|
66
|
+
usePlayerStore.setState({ elements: [], timelineReady: false });
|
|
67
|
+
});
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button";
|
|
2
|
+
export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
|
|
3
|
+
export { HyperframesLogo } from "./components/ui/HyperframesLogo";
|
|
4
|
+
export { IconButton } from "./components/ui/IconButton";
|
|
5
|
+
export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
|
|
6
|
+
export { HyperframesLoader } from "./components/ui/HyperframesLoader";
|
|
7
|
+
export type { HyperframesLoaderProps } from "./components/ui/HyperframesLoader";
|
|
8
|
+
export { Tooltip } from "./components/ui/Tooltip";
|
|
9
|
+
export { cn } from "./components/ui/cn";
|
|
10
|
+
export {
|
|
11
|
+
ContextMenu,
|
|
12
|
+
Menu,
|
|
13
|
+
MenuItem,
|
|
14
|
+
MenuRadioGroup,
|
|
15
|
+
MenuRadioItem,
|
|
16
|
+
MenuSeparator,
|
|
17
|
+
MenuShortcut,
|
|
18
|
+
popupSurface,
|
|
19
|
+
} from "./components/ui/Menu";
|
|
20
|
+
export type { MenuItemTone, PopupPreviewState } from "./components/ui/Menu";
|
|
21
|
+
export { Popover } from "./components/ui/Popover";
|
|
22
|
+
export { Input, fieldBase, fieldText } from "./components/ui/Input";
|
|
23
|
+
export type { InputProps } from "./components/ui/Input";
|
|
24
|
+
export { NumberField } from "./components/ui/NumberField";
|
|
25
|
+
export type { NumberFieldProps } from "./components/ui/NumberField";
|
|
26
|
+
export { Select } from "./components/ui/Select";
|
|
27
|
+
export type { SelectOption, SelectProps } from "./components/ui/Select";
|
|
28
|
+
export { Slider } from "./components/ui/Slider";
|
|
29
|
+
export type { SliderProps } from "./components/ui/Slider";
|
|
30
|
+
export { Toggle } from "./components/ui/Toggle";
|
|
31
|
+
export type { ToggleProps } from "./components/ui/Toggle";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { AUDIO_EXT, IMAGE_EXT, isMediaFile, MEDIA_EXT, VIDEO_EXT } from "./mediaTypes";
|
|
3
|
+
import { getTimelineAssetKind } from "./timelineAssetDrop";
|
|
4
|
+
|
|
5
|
+
describe("media types", () => {
|
|
6
|
+
it.each([
|
|
7
|
+
["clip.m4v", "video"],
|
|
8
|
+
["assets/Take 2.M4V", "video"],
|
|
9
|
+
["song.flac", "audio"],
|
|
10
|
+
["voice.FLAC", "audio"],
|
|
11
|
+
["clip.mp4", "video"],
|
|
12
|
+
["music.mp3", "audio"],
|
|
13
|
+
["still.png", "image"],
|
|
14
|
+
] as const)("the timeline takes %s as %s", (path, kind) => {
|
|
15
|
+
expect(getTimelineAssetKind(path)).toBe(kind);
|
|
16
|
+
expect(isMediaFile(path)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("still refuses files that are not media", () => {
|
|
20
|
+
for (const path of ["notes.pdf", "font.woff2", "grade.cube", "clip.m4v.txt", "flac"]) {
|
|
21
|
+
expect(getTimelineAssetKind(path)).toBeNull();
|
|
22
|
+
expect(isMediaFile(path)).toBe(false);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("counts a file as media exactly when it is an image, video or audio file", () => {
|
|
27
|
+
const paths = ["a.mp4", "a.m4v", "a.flac", "a.png", "a.ico", "a.woff", "a.pdf", "a.cube"];
|
|
28
|
+
for (const path of paths) {
|
|
29
|
+
const kinds = [IMAGE_EXT, VIDEO_EXT, AUDIO_EXT].filter((re) => re.test(path)).length;
|
|
30
|
+
expect(MEDIA_EXT.test(path)).toBe(kinds > 0);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/utils/mediaTypes.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
const IMAGE_EXTS = ["jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "ico"];
|
|
2
|
+
const VIDEO_EXTS = ["mp4", "webm", "mov", "m4v"];
|
|
3
|
+
const AUDIO_EXTS = ["mp3", "wav", "ogg", "m4a", "aac", "flac"];
|
|
4
|
+
|
|
5
|
+
function extensionPattern(extensions: readonly string[]): RegExp {
|
|
6
|
+
return new RegExp(`\\.(${extensions.join("|")})$`, "i");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const IMAGE_EXT = extensionPattern(IMAGE_EXTS);
|
|
10
|
+
export const VIDEO_EXT = extensionPattern(VIDEO_EXTS);
|
|
11
|
+
export const AUDIO_EXT = extensionPattern(AUDIO_EXTS);
|
|
4
12
|
export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
|
|
5
13
|
export const LUT_EXT = /\.cube$/i;
|
|
6
|
-
export const MEDIA_EXT =
|
|
7
|
-
/\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
|
|
14
|
+
export const MEDIA_EXT = extensionPattern([...VIDEO_EXTS, ...AUDIO_EXTS, ...IMAGE_EXTS]);
|
|
8
15
|
|
|
9
16
|
export function isMediaFile(path: string): boolean {
|
|
10
17
|
return MEDIA_EXT.test(path);
|