@hyperframes/studio 0.8.108 → 0.8.110
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-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +64 -9
- package/dist/index.html +2 -2
- package/dist/index.js +8216 -6268
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +334 -0
- package/src/components/editor/mediaLinkEdits.ts +303 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +11 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
- package/src/hooks/timelineLinkEditPlan.ts +128 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +9 -8
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +49 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
- package/src/hooks/useTimelineLinkEditing.ts +158 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +58 -0
- package/src/player/components/TimelineClip.tsx +9 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +288 -31
- package/src/player/components/audioClipLink.ts +151 -27
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
- package/src/player/components/clipMenuAudioItems.tsx +44 -57
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +174 -0
- package/src/player/components/clipMenuLinkItems.tsx +166 -0
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +80 -9
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +17 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +29 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +41 -0
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +28 -0
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +3 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -83,8 +83,14 @@ interface RenderLanesOptions {
|
|
|
83
83
|
hoveredClip?: string | null;
|
|
84
84
|
renderClipContent?: React.ComponentProps<typeof TimelineLanes>["renderClipContent"];
|
|
85
85
|
snapGuide?: { time: number; type: "beat" | "clip-edge" | "playhead" } | null;
|
|
86
|
+
withoutSelectHandler?: boolean;
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
const selectHandlerFor = (
|
|
90
|
+
options: RenderLanesOptions,
|
|
91
|
+
handler: (element: TimelineElement | null) => void,
|
|
92
|
+
) => (options.withoutSelectHandler ? undefined : handler);
|
|
93
|
+
|
|
88
94
|
function renderLanes(options: RenderLanesOptions = {}): {
|
|
89
95
|
host: HTMLDivElement;
|
|
90
96
|
root: Root;
|
|
@@ -163,7 +169,6 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
163
169
|
setResizingClip={vi.fn()}
|
|
164
170
|
setDraggedClip={vi.fn()}
|
|
165
171
|
setSelectedElementId={setSelectedElementId}
|
|
166
|
-
shiftClickClipRef={createRef()}
|
|
167
172
|
getPreviewElement={(el) => el}
|
|
168
173
|
getTrackStyle={getTrackStyle}
|
|
169
174
|
gsapAnimations={gsapAnimations}
|
|
@@ -174,7 +179,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
174
179
|
onTogglePropertyGroupKeyframe={vi.fn()}
|
|
175
180
|
onResizeElement={vi.fn()}
|
|
176
181
|
onMoveElement={vi.fn()}
|
|
177
|
-
onSelectElement={onSelectElement}
|
|
182
|
+
onSelectElement={selectHandlerFor(next, onSelectElement)}
|
|
178
183
|
onRazorSplit={vi.fn()}
|
|
179
184
|
onRazorSplitAll={vi.fn()}
|
|
180
185
|
/>,
|
|
@@ -439,6 +444,44 @@ describe("TimelineLanes selection", () => {
|
|
|
439
444
|
expect(view.onSelectElement).toHaveBeenCalledWith(selected);
|
|
440
445
|
act(() => view.root.unmount());
|
|
441
446
|
});
|
|
447
|
+
|
|
448
|
+
it.each([["shiftKey"], ["metaKey"], ["ctrlKey"]])(
|
|
449
|
+
"%s-click adds a clip to the selection instead of replacing it",
|
|
450
|
+
(modifier) => {
|
|
451
|
+
const first = element("clip-a", TRACK_A);
|
|
452
|
+
const second = element("clip-b", TRACK_B);
|
|
453
|
+
usePlayerStore.getState().setElements([first, second]);
|
|
454
|
+
usePlayerStore.getState().setSelection([first.id], first.id);
|
|
455
|
+
const view = renderLanes({ elements: [first, second] });
|
|
456
|
+
const clipB = view.host.querySelector('[data-el-id="clip-b"]');
|
|
457
|
+
const click = (init: MouseEventInit) =>
|
|
458
|
+
act(() => clipB?.dispatchEvent(new MouseEvent("click", { bubbles: true, ...init })));
|
|
459
|
+
|
|
460
|
+
click({ [modifier]: true });
|
|
461
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a", "clip-b"]));
|
|
462
|
+
expect(view.setSelectedElementId).not.toHaveBeenCalled();
|
|
463
|
+
expect(view.onSelectElement).toHaveBeenLastCalledWith(second);
|
|
464
|
+
|
|
465
|
+
click({ [modifier]: true });
|
|
466
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a"]));
|
|
467
|
+
expect(view.onSelectElement).toHaveBeenLastCalledWith(first);
|
|
468
|
+
act(() => view.root.unmount());
|
|
469
|
+
},
|
|
470
|
+
);
|
|
471
|
+
it("Cmd-click adds a clip in a host that passes no onSelectElement", () => {
|
|
472
|
+
const first = element("clip-a", TRACK_A);
|
|
473
|
+
const second = element("clip-b", TRACK_B);
|
|
474
|
+
usePlayerStore.getState().setElements([first, second]);
|
|
475
|
+
usePlayerStore.getState().setSelection([first.id], first.id);
|
|
476
|
+
const view = renderLanes({ elements: [first, second], withoutSelectHandler: true });
|
|
477
|
+
act(() =>
|
|
478
|
+
view.host
|
|
479
|
+
.querySelector('[data-el-id="clip-b"]')
|
|
480
|
+
?.dispatchEvent(new MouseEvent("click", { bubbles: true, metaKey: true })),
|
|
481
|
+
);
|
|
482
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a", "clip-b"]));
|
|
483
|
+
act(() => view.root.unmount());
|
|
484
|
+
});
|
|
442
485
|
});
|
|
443
486
|
|
|
444
487
|
describe("TimelineLanes clip thumbnails", () => {
|
|
@@ -77,7 +77,6 @@ export function TimelineLanes({
|
|
|
77
77
|
setResizingClip,
|
|
78
78
|
setDraggedClip,
|
|
79
79
|
setSelectedElementId,
|
|
80
|
-
shiftClickClipRef,
|
|
81
80
|
getPreviewElement,
|
|
82
81
|
getTrackStyle,
|
|
83
82
|
keyframeCache,
|
|
@@ -404,7 +403,6 @@ export function TimelineLanes({
|
|
|
404
403
|
onRazorSplit,
|
|
405
404
|
onRazorSplitAll,
|
|
406
405
|
blockedClipRef,
|
|
407
|
-
shiftClickClipRef,
|
|
408
406
|
suppressClickRef,
|
|
409
407
|
scrollRef,
|
|
410
408
|
setShowPopover,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TimelineAudioGainOverlay } from "./AudioGainDialog";
|
|
1
2
|
import { useEffect, useMemo } from "react";
|
|
2
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
4
|
import { EditPopover } from "./EditModal";
|
|
@@ -222,6 +223,7 @@ export function TimelineOverlays() {
|
|
|
222
223
|
<TimelineKeyframeMenuOverlay />
|
|
223
224
|
<TimelineClipMenuOverlay />
|
|
224
225
|
<TimelineGapMenuOverlay />
|
|
226
|
+
<TimelineAudioGainOverlay />
|
|
225
227
|
</>
|
|
226
228
|
);
|
|
227
229
|
}
|
|
@@ -1,34 +1,291 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
expect(audioPillFlags({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
it("
|
|
28
|
-
expect(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
3
|
+
import {
|
|
4
|
+
audioPillFlags,
|
|
5
|
+
expandToLinkedMembers,
|
|
6
|
+
dropMisalignedTrimPartners,
|
|
7
|
+
linkedGestureKeys,
|
|
8
|
+
linkedMembersOf,
|
|
9
|
+
mediaAssetIdentity,
|
|
10
|
+
} from "./audioClipLink";
|
|
11
|
+
import { createTimelineElementFromManifestClip, parseTimelineFromDOM } from "../lib/timelineDOM";
|
|
12
|
+
|
|
13
|
+
const video = { id: "talk", link: "lk-1" };
|
|
14
|
+
const audio = { id: "talk-audio", link: "lk-1" };
|
|
15
|
+
const other = { id: "music", link: "lk-2" };
|
|
16
|
+
const otherAudio = { id: "music-audio", link: "lk-2" };
|
|
17
|
+
const plain = { id: "title" };
|
|
18
|
+
const elements = [video, audio, other, otherAudio, plain];
|
|
19
|
+
|
|
20
|
+
describe("audioPillFlags", () => {
|
|
21
|
+
it("greys a hidden clip and a muted group", () => {
|
|
22
|
+
expect(audioPillFlags({ hidden: true }).muted).toBe(true);
|
|
23
|
+
expect(audioPillFlags({ audioGroupHidden: true }).muted).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("mediaAssetIdentity", () => {
|
|
28
|
+
it("tells same-named files in different folders apart", () => {
|
|
29
|
+
expect(mediaAssetIdentity({ src: "assets/one/talk.mp4" })).not.toBe(
|
|
30
|
+
mediaAssetIdentity({ src: "assets/two/talk.mp4" }),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("resolves a src against its own source file and drops query and hash", () => {
|
|
35
|
+
expect(mediaAssetIdentity({ src: "../assets/talk.mp4?v=2", sourceFile: "scenes/a.html" })).toBe(
|
|
36
|
+
mediaAssetIdentity({ src: "./assets/talk.mp4#t=1" }),
|
|
37
|
+
);
|
|
38
|
+
expect(mediaAssetIdentity({ src: "assets/City%20Ride.mp4" })).toBe(
|
|
39
|
+
mediaAssetIdentity({ src: "assets/City Ride.mp4" }),
|
|
40
|
+
);
|
|
41
|
+
expect(mediaAssetIdentity({ src: "" })).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps a remote url's query, since it can select a different file", () => {
|
|
45
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1" })).not.toBe(
|
|
46
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=2" }),
|
|
47
|
+
);
|
|
48
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a%26x=b" })).not.toBe(
|
|
49
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a&x=b" }),
|
|
50
|
+
);
|
|
51
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4%3Fclip=a" })).not.toBe(
|
|
52
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a" }),
|
|
53
|
+
);
|
|
54
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1#t=3" })).toBe(
|
|
55
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1" }),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("expandToLinkedMembers", () => {
|
|
61
|
+
it("adds every partner of a linked clip", () => {
|
|
62
|
+
expect(expandToLinkedMembers(["talk"], elements)).toEqual(new Set(["talk", "talk-audio"]));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("leaves unlinked clips alone and does not pull in other groups", () => {
|
|
66
|
+
expect(expandToLinkedMembers(["title"], elements)).toEqual(new Set(["title"]));
|
|
67
|
+
expect(expandToLinkedMembers(["talk-audio", "title"], elements)).toEqual(
|
|
68
|
+
new Set(["talk-audio", "talk", "title"]),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("prefers the store key over the id", () => {
|
|
73
|
+
const keyed = [
|
|
74
|
+
{ id: "a", key: "k-a", link: "lk-9" },
|
|
75
|
+
{ id: "b", key: "k-b", link: "lk-9" },
|
|
76
|
+
];
|
|
77
|
+
expect(expandToLinkedMembers(["k-a"], keyed)).toEqual(new Set(["k-a", "k-b"]));
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("link groups stay inside their composition", () => {
|
|
82
|
+
const root = [
|
|
83
|
+
{ id: "v", link: "lk-1", sourceFile: undefined },
|
|
84
|
+
{ id: "a", link: "lk-1", sourceFile: undefined },
|
|
85
|
+
];
|
|
86
|
+
const child = [
|
|
87
|
+
{ id: "cv", key: "child.html#cv", link: "lk-1", sourceFile: "child.html" },
|
|
88
|
+
{ id: "ca", key: "child.html#ca", link: "lk-1", sourceFile: "child.html" },
|
|
89
|
+
];
|
|
90
|
+
const all = [...root, ...child];
|
|
91
|
+
|
|
92
|
+
it("does not pull a child file's same-id link into the root group", () => {
|
|
93
|
+
expect(linkedMembersOf(root[0] ?? video, all).map((el) => el.id)).toEqual(["v", "a"]);
|
|
94
|
+
expect(expandToLinkedMembers(["child.html#cv"], all)).toEqual(
|
|
95
|
+
new Set(["child.html#cv", "child.html#ca"]),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const media = (id: string, tag: string, start: number) =>
|
|
100
|
+
`<${tag} id="${id}" class="clip" src="${id}.mp4" data-link="lk-1" data-start="${start}" data-duration="4"></${tag}>`;
|
|
101
|
+
const inlineDoc = () => {
|
|
102
|
+
const doc = document.implementation.createHTMLDocument();
|
|
103
|
+
doc.body.innerHTML = `<div data-composition-id="main" data-duration="20">
|
|
104
|
+
${media("v", "video", 0)}${media("a", "audio", 0)}
|
|
105
|
+
<div id="child" data-composition-id="child" data-start="0" data-duration="10">
|
|
106
|
+
${media("cv", "video", 5)}${media("ca", "audio", 5)}
|
|
107
|
+
</div>
|
|
108
|
+
</div>`;
|
|
109
|
+
return doc;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
it("does not pull an inline composition's same-id link into the root group", () => {
|
|
113
|
+
const rows = parseTimelineFromDOM(inlineDoc(), 20).filter((row) => row.link);
|
|
114
|
+
const byId = (id: string) => rows.find((row) => row.domId === id) ?? video;
|
|
115
|
+
expect(linkedMembersOf(byId("v"), rows).map((row) => row.domId)).toEqual(["v", "a"]);
|
|
116
|
+
expect(linkedMembersOf(byId("cv"), rows).map((row) => row.domId)).toEqual(["cv", "ca"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("keeps the inline composition apart on rows built from the runtime manifest", () => {
|
|
120
|
+
const doc = inlineDoc();
|
|
121
|
+
const rows = ["v", "a", "cv", "ca"].map((id, fallbackIndex) =>
|
|
122
|
+
createTimelineElementFromManifestClip({
|
|
123
|
+
clip: {
|
|
124
|
+
...{ id, label: id, start: 0, duration: 4, track: 0, kind: "video", tagName: "video" },
|
|
125
|
+
...{
|
|
126
|
+
compositionId: null,
|
|
127
|
+
parentCompositionId: null,
|
|
128
|
+
compositionSrc: null,
|
|
129
|
+
assetUrl: null,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
fallbackIndex,
|
|
133
|
+
doc,
|
|
134
|
+
hostEl: doc.getElementById(id),
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
const [rootVideo] = rows;
|
|
138
|
+
expect(linkedMembersOf(rootVideo ?? video, rows).map((row) => row.domId)).toEqual(["v", "a"]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("linkedMembersOf", () => {
|
|
143
|
+
it("returns the group, or the clip alone when unlinked", () => {
|
|
144
|
+
expect(linkedMembersOf(video, elements).map((el) => el.id)).toEqual(["talk", "talk-audio"]);
|
|
145
|
+
expect(linkedMembersOf(plain, elements)).toEqual([plain]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("linkedGestureKeys", () => {
|
|
150
|
+
it("drags an unselected linked clip with its partner", () => {
|
|
151
|
+
expect(linkedGestureKeys(new Set(["title"]), video, elements, false)).toEqual(
|
|
152
|
+
new Set(["talk", "talk-audio"]),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("keeps a selection that holds the grabbed clip and adds partners", () => {
|
|
157
|
+
expect(linkedGestureKeys(new Set(["talk", "title"]), video, elements, false)).toEqual(
|
|
158
|
+
new Set(["talk", "talk-audio", "title"]),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("Linked Selection off edits the grabbed clip alone without Alt", () => {
|
|
163
|
+
expect(linkedGestureKeys(new Set(["talk"]), video, elements, false, false)).toEqual(
|
|
164
|
+
new Set(["talk"]),
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("Alt edits the grabbed clip alone", () => {
|
|
169
|
+
expect(linkedGestureKeys(new Set(["talk", "talk-audio"]), video, elements, true)).toEqual(
|
|
170
|
+
new Set(["talk"]),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("selectClipWithLinks", () => {
|
|
176
|
+
const linkedPair = [
|
|
177
|
+
{ id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
|
|
178
|
+
{ id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
|
|
179
|
+
{ id: "title", tag: "div", start: 0, duration: 4, track: 2 },
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
async function store() {
|
|
183
|
+
const { usePlayerStore } = await import("../store/playerStore");
|
|
184
|
+
const { selectClipWithLinks } = await import("./timelineLinkSelection");
|
|
185
|
+
usePlayerStore.getState().setElements(linkedPair);
|
|
186
|
+
const click = (key: string, alt = false) =>
|
|
187
|
+
selectClipWithLinks(key, alt, usePlayerStore.getState().setSelectedElementId);
|
|
188
|
+
return { usePlayerStore, click };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it("a click selects the whole pair; Alt-click then narrows to one member", async () => {
|
|
192
|
+
const { usePlayerStore, click } = await store();
|
|
193
|
+
click("talk");
|
|
194
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk", "talk-audio"]));
|
|
195
|
+
click("talk-audio", true);
|
|
196
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk-audio"]));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("a plain click collapses a larger selection to the clicked clip's link group", async () => {
|
|
200
|
+
const { usePlayerStore, click } = await store();
|
|
201
|
+
usePlayerStore.getState().setSelection(["talk", "talk-audio", "title"], "title");
|
|
202
|
+
click("talk");
|
|
203
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk", "talk-audio"]));
|
|
204
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("talk");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("toggleClipWithLinks", () => {
|
|
209
|
+
const clips = [
|
|
210
|
+
{ id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
|
|
211
|
+
{ id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
|
|
212
|
+
{ id: "title", tag: "div", start: 0, duration: 4, track: 2 },
|
|
213
|
+
{ id: "music", tag: "audio", start: 0, duration: 4, track: 3 },
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
async function store() {
|
|
217
|
+
const { usePlayerStore } = await import("../store/playerStore");
|
|
218
|
+
const { toggleClipWithLinks } = await import("./timelineLinkSelection");
|
|
219
|
+
usePlayerStore.getState().setElements(clips);
|
|
220
|
+
usePlayerStore.getState().setSelection(["title"], "title");
|
|
221
|
+
return { usePlayerStore, toggle: toggleClipWithLinks };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
it("adds an unlinked clip to the selection and makes it primary", async () => {
|
|
225
|
+
const { usePlayerStore, toggle } = await store();
|
|
226
|
+
expect(toggle("music", false)?.id).toBe("music");
|
|
227
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title", "music"]));
|
|
228
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("music");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("adding a linked clip adds its partner too", async () => {
|
|
232
|
+
const { usePlayerStore, toggle } = await store();
|
|
233
|
+
toggle("talk-audio", false);
|
|
234
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(
|
|
235
|
+
new Set(["title", "talk", "talk-audio"]),
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("toggling a selected clip removes it and its partner, keeping the rest", async () => {
|
|
240
|
+
const { usePlayerStore, toggle } = await store();
|
|
241
|
+
toggle("talk", false);
|
|
242
|
+
expect(toggle("talk", false)?.id).toBe("title");
|
|
243
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title"]));
|
|
244
|
+
expect(toggle("title", false)).toBeNull();
|
|
245
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("Alt adds only the clicked member of a linked pair", async () => {
|
|
249
|
+
const { usePlayerStore, toggle } = await store();
|
|
250
|
+
toggle("talk", true);
|
|
251
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title", "talk"]));
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("Linked Selection off", () => {
|
|
256
|
+
const linkedPair = [
|
|
257
|
+
{ id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
|
|
258
|
+
{ id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
it("expands and lists nothing past the clip itself", () => {
|
|
262
|
+
expect(expandToLinkedMembers(["talk"], linkedPair, false)).toEqual(new Set(["talk"]));
|
|
263
|
+
expect(linkedMembersOf(linkedPair[0], linkedPair, false)).toEqual([linkedPair[0]]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("a click selects only the clicked clip", async () => {
|
|
267
|
+
const { usePlayerStore } = await import("../store/playerStore");
|
|
268
|
+
const { selectClipWithLinks } = await import("./timelineLinkSelection");
|
|
269
|
+
const { useLinkedClipPreferences } = await import("../../utils/linkedClipPreferences");
|
|
270
|
+
usePlayerStore.getState().setElements(linkedPair);
|
|
271
|
+
useLinkedClipPreferences.getState().setLinkedSelection(false);
|
|
272
|
+
try {
|
|
273
|
+
selectClipWithLinks("talk", false, usePlayerStore.getState().setSelectedElementId);
|
|
274
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk"]));
|
|
275
|
+
} finally {
|
|
276
|
+
useLinkedClipPreferences.getState().setLinkedSelection(true);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("dropMisalignedTrimPartners", () => {
|
|
282
|
+
const v = { id: "v", link: "lk", start: 2, duration: 6 };
|
|
283
|
+
const a = { id: "a", link: "lk", start: 3, duration: 5 };
|
|
284
|
+
const keys = new Set(["v", "a"]);
|
|
285
|
+
it("keeps a partner whose grabbed edge is at the same time", () => {
|
|
286
|
+
expect(dropMisalignedTrimPartners(keys, v, [v, a], "end")).toEqual(keys);
|
|
287
|
+
});
|
|
288
|
+
it("trims only the grabbed clip when the partner's edge is elsewhere", () => {
|
|
289
|
+
expect(dropMisalignedTrimPartners(keys, v, [v, a], "start")).toEqual(new Set(["v"]));
|
|
33
290
|
});
|
|
34
291
|
});
|
|
@@ -1,33 +1,157 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { sameCompositionScope, type TimelineElement } from "../store/timelineElement";
|
|
2
|
+
import { syncPartnerOf } from "./clipSync";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
type LinkScoped = Pick<TimelineElement, "sourceFile" | "compositionScope">;
|
|
5
|
+
type LinkedElement = Pick<TimelineElement, "id" | "key" | "link"> & LinkScoped;
|
|
6
|
+
|
|
7
|
+
const keyOf = (element: Pick<TimelineElement, "id" | "key">) => element.key ?? element.id;
|
|
8
|
+
|
|
9
|
+
const SOURCE_BASE = "https://project.invalid/";
|
|
10
|
+
const SOURCE_ORIGIN = new URL(SOURCE_BASE).origin;
|
|
11
|
+
|
|
12
|
+
export function mediaAssetIdentity(
|
|
13
|
+
element: Pick<TimelineElement, "src" | "sourceFile">,
|
|
14
|
+
): string | null {
|
|
15
|
+
const src = element.src?.trim();
|
|
4
16
|
if (!src) return null;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(src, new URL(element.sourceFile ?? "index.html", SOURCE_BASE));
|
|
19
|
+
if (url.origin !== SOURCE_ORIGIN) return `${url.origin}${url.pathname}${url.search}`;
|
|
20
|
+
return decodeURIComponent(`${url.origin}${url.pathname}`);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
8
24
|
}
|
|
9
25
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** An audio pill whose file is also a video clip on this timeline. */
|
|
21
|
-
export function isLinkedVideoAudio(
|
|
22
|
-
audio: Pick<TimelineElement, "id" | "tag" | "src">,
|
|
23
|
-
elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
|
|
26
|
+
function isLinked(element: Pick<TimelineElement, "link">): boolean {
|
|
27
|
+
return typeof element.link === "string" && element.link.length > 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function sharesLinkGroup(
|
|
31
|
+
a: Pick<TimelineElement, "link"> & LinkScoped,
|
|
32
|
+
b: Pick<TimelineElement, "link"> & LinkScoped,
|
|
24
33
|
): boolean {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
34
|
+
return isLinked(a) && a.link === b.link && sameCompositionScope(a, b);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function audioPillFlags(
|
|
38
|
+
audio: Pick<TimelineElement, "hidden" | "audioGroupHidden">,
|
|
39
|
+
_elements?: readonly unknown[],
|
|
40
|
+
): { muted: boolean } {
|
|
41
|
+
return { muted: audio.hidden === true || audio.audioGroupHidden === true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function linkedMembersOf<T extends LinkedElement>(
|
|
45
|
+
element: T,
|
|
46
|
+
elements: readonly T[],
|
|
47
|
+
linked = true,
|
|
48
|
+
): T[] {
|
|
49
|
+
if (!linked || !isLinked(element)) return [element];
|
|
50
|
+
const members = elements.filter((candidate) => sharesLinkGroup(candidate, element));
|
|
51
|
+
return members.some((member) => keyOf(member) === keyOf(element))
|
|
52
|
+
? members
|
|
53
|
+
: [element, ...members];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function expandToLinkedMembers(
|
|
57
|
+
keys: Iterable<string>,
|
|
58
|
+
elements: readonly LinkedElement[],
|
|
59
|
+
linked = true,
|
|
60
|
+
): Set<string> {
|
|
61
|
+
const expanded = new Set(keys);
|
|
62
|
+
if (!linked) return expanded;
|
|
63
|
+
const seeds = elements.filter((el) => expanded.has(keyOf(el)) && isLinked(el));
|
|
64
|
+
for (const element of elements) {
|
|
65
|
+
if (seeds.some((seed) => sharesLinkGroup(seed, element))) expanded.add(keyOf(element));
|
|
66
|
+
}
|
|
67
|
+
return expanded;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function linkedGestureKeys(
|
|
71
|
+
selected: ReadonlySet<string>,
|
|
72
|
+
grabbed: LinkedElement,
|
|
73
|
+
elements: readonly LinkedElement[],
|
|
74
|
+
altKey: boolean,
|
|
75
|
+
linked = true,
|
|
76
|
+
): Set<string> {
|
|
77
|
+
const grabbedKey = keyOf(grabbed);
|
|
78
|
+
if (altKey) return new Set([grabbedKey]);
|
|
79
|
+
const base = selected.has(grabbedKey) ? selected : [grabbedKey];
|
|
80
|
+
return expandToLinkedMembers(base, elements, linked);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type TimedLinked = LinkedElement & Pick<TimelineElement, "start" | "duration">;
|
|
84
|
+
|
|
85
|
+
const edgeTime = (el: Pick<TimelineElement, "start" | "duration">, edge: "start" | "end") =>
|
|
86
|
+
edge === "start" ? el.start : el.start + el.duration;
|
|
87
|
+
|
|
88
|
+
export function dropMisalignedTrimPartners(
|
|
89
|
+
keys: ReadonlySet<string>,
|
|
90
|
+
grabbed: TimedLinked,
|
|
91
|
+
elements: readonly TimedLinked[],
|
|
92
|
+
edge: "start" | "end",
|
|
93
|
+
): Set<string> {
|
|
94
|
+
const kept = new Set(keys);
|
|
95
|
+
if (!isLinked(grabbed)) return kept;
|
|
96
|
+
for (const el of elements) {
|
|
97
|
+
if (!sharesLinkGroup(el, grabbed) || keyOf(el) === keyOf(grabbed)) continue;
|
|
98
|
+
if (Math.abs(edgeTime(el, edge) - edgeTime(grabbed, edge)) > 1e-3) kept.delete(keyOf(el));
|
|
99
|
+
}
|
|
100
|
+
return kept;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type BoundedElement = Pick<
|
|
104
|
+
TimelineElement,
|
|
105
|
+
| "id"
|
|
106
|
+
| "key"
|
|
107
|
+
| "tag"
|
|
108
|
+
| "start"
|
|
109
|
+
| "duration"
|
|
110
|
+
| "link"
|
|
111
|
+
| "syncOrigin"
|
|
112
|
+
| "playbackStart"
|
|
113
|
+
| "playbackRate"
|
|
114
|
+
| "sourceFile"
|
|
115
|
+
>;
|
|
116
|
+
|
|
117
|
+
const tagOf = (el: Pick<TimelineElement, "tag">) => el.tag.trim().toLowerCase();
|
|
118
|
+
|
|
119
|
+
function partnerVideoBounds(
|
|
120
|
+
audio: BoundedElement,
|
|
121
|
+
elements: readonly BoundedElement[],
|
|
122
|
+
): { videoKey: string; start: number; end: number } | null {
|
|
123
|
+
if (tagOf(audio) !== "audio") return null;
|
|
124
|
+
const linkedVideo = isLinked(audio)
|
|
125
|
+
? elements.find((el) => sharesLinkGroup(el, audio) && tagOf(el) === "video")
|
|
126
|
+
: undefined;
|
|
127
|
+
const video = linkedVideo ?? syncPartnerOf(audio, elements);
|
|
128
|
+
return video
|
|
129
|
+
? { videoKey: keyOf(video), start: video.start, end: video.start + video.duration }
|
|
130
|
+
: null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function heldAudioShiftRange(
|
|
134
|
+
movers: readonly BoundedElement[],
|
|
135
|
+
elements: readonly BoundedElement[],
|
|
136
|
+
moving: ReadonlySet<string>,
|
|
137
|
+
): { min: number; max: number } {
|
|
138
|
+
let min = Number.NEGATIVE_INFINITY;
|
|
139
|
+
let max = Number.POSITIVE_INFINITY;
|
|
140
|
+
for (const mover of movers) {
|
|
141
|
+
const bounds = heldPartnerVideoBounds(mover, elements, moving);
|
|
142
|
+
if (!bounds) continue;
|
|
143
|
+
const low = bounds.start - mover.start;
|
|
144
|
+
min = Math.max(min, low);
|
|
145
|
+
max = Math.min(max, Math.max(low, bounds.end - mover.duration - mover.start));
|
|
146
|
+
}
|
|
147
|
+
return { min, max };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function heldPartnerVideoBounds(
|
|
151
|
+
audio: BoundedElement,
|
|
152
|
+
elements: readonly BoundedElement[],
|
|
153
|
+
gestureKeys: ReadonlySet<string>,
|
|
154
|
+
): { start: number; end: number } | null {
|
|
155
|
+
const bounds = partnerVideoBounds(audio, elements);
|
|
156
|
+
return bounds && !gestureKeys.has(bounds.videoKey) ? bounds : null;
|
|
33
157
|
}
|