@hyperframes/studio 0.8.107 → 0.8.109
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-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
- package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +53 -4
- package/dist/index.html +2 -2
- package/dist/index.js +15746 -13173
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -11
- package/src/components/EditorShell.tsx +6 -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/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- 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/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +79 -4
- package/src/components/editor/mediaAudioEdits.ts +66 -24
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -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/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -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.ts +9 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- 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 +26 -2
- package/src/hooks/useRenderClipContent.test.ts +108 -4
- package/src/hooks/useRenderClipContent.ts +33 -23
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +38 -57
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +2 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -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 +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- 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 +124 -77
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +8 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
- package/src/player/components/clipMenuAudioItems.tsx +116 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +147 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +233 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -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 +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +12 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- 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/lib/timelineElementHelpers.ts +9 -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/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- 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/previewRelativePath.ts +19 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { readClipBadges, splitVisibleBadges, type ClipBadge } from "./clipToolAttrs";
|
|
4
|
+
import { useClipToolState } from "./useClipToolState";
|
|
5
|
+
|
|
6
|
+
function SpeakerGlyph({ muted }: { muted: boolean }) {
|
|
7
|
+
return (
|
|
8
|
+
<svg width="10" height="10" viewBox="0 0 16 16" aria-hidden="true">
|
|
9
|
+
<path d="M2 6h3l4-3v10l-4-3H2z" fill="currentColor" />
|
|
10
|
+
{muted ? (
|
|
11
|
+
<path d="M11 6l4 4M15 6l-4 4" stroke="currentColor" strokeWidth="1.4" />
|
|
12
|
+
) : (
|
|
13
|
+
<path
|
|
14
|
+
d="M11 5.5a3.5 3.5 0 0 1 0 5M12.5 3.5a6 6 0 0 1 0 9"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
fill="none"
|
|
17
|
+
strokeWidth="1.4"
|
|
18
|
+
/>
|
|
19
|
+
)}
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function BadgeContent({ badge }: { badge: ClipBadge }) {
|
|
25
|
+
if (badge.kind === "link") return <span aria-hidden="true">🔗</span>;
|
|
26
|
+
if (badge.kind === "volume") {
|
|
27
|
+
const muted = badge.label === "Muted";
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<SpeakerGlyph muted={muted} />
|
|
31
|
+
{muted ? null : <span>{badge.label}</span>}
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return <span>{badge.label}</span>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const BADGE_CLASS =
|
|
39
|
+
"inline-flex items-center gap-0.5 rounded-[3px] border border-white/20 bg-black/55 px-1 text-[9px] leading-[14px] text-white/90 whitespace-nowrap";
|
|
40
|
+
|
|
41
|
+
/** What is applied to a clip, read from its attributes: a link badge, then at most two more and `+N`. */
|
|
42
|
+
export const ClipBadges = memo(function ClipBadges({ el }: { el: TimelineElement }) {
|
|
43
|
+
const state = useClipToolState(el);
|
|
44
|
+
const { visible, hidden } = splitVisibleBadges(readClipBadges(state));
|
|
45
|
+
if (visible.length === 0) return null;
|
|
46
|
+
return (
|
|
47
|
+
<span
|
|
48
|
+
className="pointer-events-none absolute right-1.5 top-0.5 z-[3] flex gap-1"
|
|
49
|
+
data-testid="clip-badges"
|
|
50
|
+
>
|
|
51
|
+
{visible.map((badge) => (
|
|
52
|
+
<span key={badge.kind} className={BADGE_CLASS} title={badge.label} data-badge={badge.kind}>
|
|
53
|
+
<BadgeContent badge={badge} />
|
|
54
|
+
</span>
|
|
55
|
+
))}
|
|
56
|
+
{hidden.length > 0 && (
|
|
57
|
+
<span
|
|
58
|
+
className={`${BADGE_CLASS} pointer-events-auto`}
|
|
59
|
+
title={hidden.map((badge) => badge.label).join(", ")}
|
|
60
|
+
data-badge="more"
|
|
61
|
+
>
|
|
62
|
+
+{hidden.length}
|
|
63
|
+
</span>
|
|
64
|
+
)}
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
5
|
+
import { ClipContextMenu } from "./ClipContextMenu";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
8
|
+
|
|
9
|
+
const iframe = document.createElement("iframe");
|
|
10
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
11
|
+
useStudioShellContextOptional: () => ({
|
|
12
|
+
projectId: "p1",
|
|
13
|
+
showToast: vi.fn(),
|
|
14
|
+
previewIframeRef: { current: iframe },
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const harness = createHappyDomRootHarness();
|
|
19
|
+
|
|
20
|
+
const talk: TimelineElement = {
|
|
21
|
+
id: "talk",
|
|
22
|
+
domId: "talk",
|
|
23
|
+
tag: "video",
|
|
24
|
+
src: "talk.mp4",
|
|
25
|
+
start: 0,
|
|
26
|
+
duration: 6,
|
|
27
|
+
track: 0,
|
|
28
|
+
hasAudio: true,
|
|
29
|
+
link: "lk-1",
|
|
30
|
+
};
|
|
31
|
+
const talkAudio: TimelineElement = {
|
|
32
|
+
...talk,
|
|
33
|
+
id: "talk-a",
|
|
34
|
+
domId: "talk-a",
|
|
35
|
+
tag: "audio",
|
|
36
|
+
track: 1,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function renderMenu(element: TimelineElement) {
|
|
40
|
+
usePlayerStore.getState().setElements([talk, talkAudio]);
|
|
41
|
+
document.body.appendChild(iframe);
|
|
42
|
+
const node = iframe.contentDocument?.createElement("video");
|
|
43
|
+
if (node) {
|
|
44
|
+
node.id = "talk";
|
|
45
|
+
node.setAttribute("data-has-audio", "true");
|
|
46
|
+
iframe.contentDocument?.body.appendChild(node);
|
|
47
|
+
}
|
|
48
|
+
const host = document.createElement("div");
|
|
49
|
+
document.body.appendChild(host);
|
|
50
|
+
const noop = vi.fn();
|
|
51
|
+
act(() =>
|
|
52
|
+
harness.mount(host).render(
|
|
53
|
+
<TimelineEditProvider
|
|
54
|
+
value={{
|
|
55
|
+
onSetElementAttributeQuiet: vi.fn(async () => undefined),
|
|
56
|
+
onFreezeFrame: noop,
|
|
57
|
+
onLinkEdit: noop,
|
|
58
|
+
onDeleteElementOnly: noop,
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<ClipContextMenu
|
|
62
|
+
x={10}
|
|
63
|
+
y={10}
|
|
64
|
+
element={element}
|
|
65
|
+
currentTime={2}
|
|
66
|
+
onClose={noop}
|
|
67
|
+
onSplit={noop}
|
|
68
|
+
onDelete={noop}
|
|
69
|
+
/>
|
|
70
|
+
</TimelineEditProvider>,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function menuLabels(): string[] {
|
|
76
|
+
return Array.from(document.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]')).map(
|
|
77
|
+
(item) => item.querySelector("span")?.textContent ?? "",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("ClipContextMenu order", () => {
|
|
82
|
+
it("groups time, sound, picture, then delete for a linked video with sound", () => {
|
|
83
|
+
renderMenu(talk);
|
|
84
|
+
const expected = [
|
|
85
|
+
"Split at 2.00s",
|
|
86
|
+
"Freeze frame",
|
|
87
|
+
"Normalize loudness",
|
|
88
|
+
"Voice",
|
|
89
|
+
"Detach audio",
|
|
90
|
+
"Unlink from audio",
|
|
91
|
+
"Look",
|
|
92
|
+
"Crop",
|
|
93
|
+
"Delete",
|
|
94
|
+
"Delete this clip only",
|
|
95
|
+
];
|
|
96
|
+
expect(menuLabels().filter((label) => expected.includes(label))).toEqual(expected);
|
|
97
|
+
const groups = Array.from(document.querySelectorAll('[role="group"]')).map((group) =>
|
|
98
|
+
group.getAttribute("aria-label"),
|
|
99
|
+
);
|
|
100
|
+
expect(groups).toEqual(["Time", "Sound", "Picture", "Clipboard", "Delete"]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -60,9 +60,14 @@ describe("ClipContextMenu host items", () => {
|
|
|
60
60
|
unmount();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it("
|
|
63
|
+
it("keeps the menu, host rows included, inside the window by its measured height", () => {
|
|
64
64
|
const innerHeight = Object.getOwnPropertyDescriptor(window, "innerHeight");
|
|
65
65
|
Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
|
|
66
|
+
const offsetHeight = vi
|
|
67
|
+
.spyOn(HTMLElement.prototype, "offsetHeight", "get")
|
|
68
|
+
.mockImplementation(function (this: HTMLElement) {
|
|
69
|
+
return this.getAttribute("role") === "menu" ? 150 : 0;
|
|
70
|
+
});
|
|
66
71
|
try {
|
|
67
72
|
const host = document.createElement("div");
|
|
68
73
|
document.body.appendChild(host);
|
|
@@ -86,10 +91,11 @@ describe("ClipContextMenu host items", () => {
|
|
|
86
91
|
/>,
|
|
87
92
|
),
|
|
88
93
|
);
|
|
89
|
-
//
|
|
90
|
-
expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("
|
|
94
|
+
// 150 px measured from y 550 ends 100 px below the window, kept 8 px off its edge.
|
|
95
|
+
expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("442px");
|
|
91
96
|
act(() => root.unmount());
|
|
92
97
|
} finally {
|
|
98
|
+
offsetHeight.mockRestore();
|
|
93
99
|
if (innerHeight) Object.defineProperty(window, "innerHeight", innerHeight);
|
|
94
100
|
}
|
|
95
101
|
});
|
|
@@ -106,7 +112,7 @@ describe("ClipContextMenu host items", () => {
|
|
|
106
112
|
it("shows only Studio's items when the host adds none", () => {
|
|
107
113
|
const { items, unmount } = renderMenu([]);
|
|
108
114
|
expect(items().map((item) => item.textContent)).toEqual(["Copy⌘C", "Delete⌫"]);
|
|
109
|
-
expect(document.body.
|
|
115
|
+
expect(document.body.querySelector("[role=group][aria-label=Host]")).toBeNull();
|
|
110
116
|
unmount();
|
|
111
117
|
});
|
|
112
118
|
});
|
|
@@ -1,10 +1,31 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
1
|
+
import { memo, useLayoutEffect, useState, type RefObject } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
4
4
|
import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
5
5
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
6
6
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
7
7
|
import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
8
|
+
import { ClipMenuToolItems } from "./clipMenuToolItems";
|
|
9
|
+
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
|
|
10
|
+
import { ClipMenuLinkItems } from "./clipMenuLinkItems";
|
|
11
|
+
|
|
12
|
+
const MENU_MARGIN = 8;
|
|
13
|
+
// Empty groups collapse; every non-empty group before the always-present Delete group ends in a divider.
|
|
14
|
+
const GROUP_CLASS = "empty:hidden mb-1 pb-1 border-b border-neutral-700/60";
|
|
15
|
+
|
|
16
|
+
function useMeasuredHeight(ref: RefObject<HTMLDivElement | null>, anchorKey: string): number {
|
|
17
|
+
const [height, setHeight] = useState(0);
|
|
18
|
+
useLayoutEffect(() => {
|
|
19
|
+
const node = ref.current;
|
|
20
|
+
if (!node) return;
|
|
21
|
+
setHeight(node.offsetHeight);
|
|
22
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
23
|
+
const observer = new ResizeObserver(() => setHeight(node.offsetHeight));
|
|
24
|
+
observer.observe(node);
|
|
25
|
+
return () => observer.disconnect();
|
|
26
|
+
}, [ref, anchorKey]);
|
|
27
|
+
return height;
|
|
28
|
+
}
|
|
8
29
|
|
|
9
30
|
interface ClipContextMenuProps {
|
|
10
31
|
x: number;
|
|
@@ -60,7 +81,6 @@ function HostItems({
|
|
|
60
81
|
)}
|
|
61
82
|
</button>
|
|
62
83
|
))}
|
|
63
|
-
<div className="my-1 border-t border-neutral-700/60" />
|
|
64
84
|
</>
|
|
65
85
|
);
|
|
66
86
|
}
|
|
@@ -103,16 +123,11 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
103
123
|
? `Split at ${currentTime.toFixed(2)}s`
|
|
104
124
|
: "Split (move playhead inside clip)";
|
|
105
125
|
|
|
106
|
-
const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
|
|
107
|
-
const hostRows = hostItems.length;
|
|
108
|
-
const rowCount = hostRows + (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
|
|
109
|
-
const dividerCount =
|
|
110
|
-
(hostRows > 0 ? 1 : 0) + (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
|
|
111
126
|
const menuWidth = 200;
|
|
112
|
-
const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
|
|
113
|
-
const overflowY = y + menuHeight - window.innerHeight;
|
|
114
127
|
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
115
|
-
const
|
|
128
|
+
const menuHeight = useMeasuredHeight(menuRef, `${x},${y},${element.key ?? element.id}`);
|
|
129
|
+
const overflowY = y + menuHeight - window.innerHeight;
|
|
130
|
+
const adjustedY = overflowY > 0 ? Math.max(MENU_MARGIN, y - overflowY - MENU_MARGIN) : y;
|
|
116
131
|
|
|
117
132
|
return createPortal(
|
|
118
133
|
<div
|
|
@@ -122,9 +137,13 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
122
137
|
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
123
138
|
style={{ left: adjustedX, top: adjustedY }}
|
|
124
139
|
>
|
|
125
|
-
{
|
|
126
|
-
|
|
127
|
-
|
|
140
|
+
{hostItems.length > 0 && (
|
|
141
|
+
<div role="group" aria-label="Host" className={GROUP_CLASS}>
|
|
142
|
+
<HostItems items={hostItems} onClose={onClose} />
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
<div role="group" aria-label="Time" className={GROUP_CLASS}>
|
|
146
|
+
{splitLabel && (
|
|
128
147
|
<button
|
|
129
148
|
type="button"
|
|
130
149
|
role="menuitem"
|
|
@@ -144,72 +163,100 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
144
163
|
<span>{splitLabel}</span>
|
|
145
164
|
<span className="text-neutral-500 text-[10px] ml-3">S</span>
|
|
146
165
|
</button>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
166
|
+
)}
|
|
167
|
+
{splitLabel && (
|
|
168
|
+
<ClipMenuToolItems
|
|
169
|
+
group="time"
|
|
170
|
+
element={element}
|
|
171
|
+
currentTime={currentTime}
|
|
172
|
+
onClose={onClose}
|
|
173
|
+
/>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
150
176
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
{
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
className={itemClass(!!canPaste)}
|
|
172
|
-
disabled={!canPaste}
|
|
173
|
-
onClick={() => {
|
|
174
|
-
if (!canPaste) return;
|
|
175
|
-
void onPaste();
|
|
176
|
-
onClose();
|
|
177
|
-
}}
|
|
178
|
-
>
|
|
179
|
-
<span>Paste</span>
|
|
180
|
-
<span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
|
|
181
|
-
</button>
|
|
182
|
-
)}
|
|
183
|
-
{onDuplicate && (
|
|
184
|
-
<button
|
|
185
|
-
type="button"
|
|
186
|
-
role="menuitem"
|
|
187
|
-
className={itemClass(true)}
|
|
188
|
-
onClick={() => {
|
|
189
|
-
void onDuplicate();
|
|
190
|
-
onClose();
|
|
191
|
-
}}
|
|
192
|
-
>
|
|
193
|
-
<span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
|
|
194
|
-
<span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
|
|
195
|
-
</button>
|
|
196
|
-
)}
|
|
197
|
-
<div className="my-1 border-t border-neutral-700/60" />
|
|
198
|
-
</>
|
|
199
|
-
)}
|
|
177
|
+
<div role="group" aria-label="Sound" className={GROUP_CLASS}>
|
|
178
|
+
<ClipMenuAudioItems part="normalize" element={element} onClose={onClose} />
|
|
179
|
+
<ClipMenuToolItems
|
|
180
|
+
group="sound"
|
|
181
|
+
element={element}
|
|
182
|
+
currentTime={currentTime}
|
|
183
|
+
onClose={onClose}
|
|
184
|
+
/>
|
|
185
|
+
<ClipMenuLinkItems part="link" element={element} onClose={onClose} />
|
|
186
|
+
<ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<div role="group" aria-label="Picture" className={GROUP_CLASS}>
|
|
190
|
+
<ClipMenuToolItems
|
|
191
|
+
group="picture"
|
|
192
|
+
element={element}
|
|
193
|
+
currentTime={currentTime}
|
|
194
|
+
onClose={onClose}
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
200
197
|
|
|
201
|
-
<
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
198
|
+
<div role="group" aria-label="Clipboard" className={GROUP_CLASS}>
|
|
199
|
+
{onCopy && (
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
role="menuitem"
|
|
203
|
+
className={itemClass(true)}
|
|
204
|
+
onClick={() => {
|
|
205
|
+
onCopy();
|
|
206
|
+
onClose();
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
<span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
|
|
210
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
|
|
211
|
+
</button>
|
|
212
|
+
)}
|
|
213
|
+
{onPaste && (
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
role="menuitem"
|
|
217
|
+
className={itemClass(!!canPaste)}
|
|
218
|
+
disabled={!canPaste}
|
|
219
|
+
onClick={() => {
|
|
220
|
+
if (!canPaste) return;
|
|
221
|
+
void onPaste();
|
|
222
|
+
onClose();
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
<span>Paste</span>
|
|
226
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
|
|
227
|
+
</button>
|
|
228
|
+
)}
|
|
229
|
+
{onDuplicate && (
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
role="menuitem"
|
|
233
|
+
className={itemClass(true)}
|
|
234
|
+
onClick={() => {
|
|
235
|
+
void onDuplicate();
|
|
236
|
+
onClose();
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
|
|
240
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<div role="group" aria-label="Delete">
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
role="menuitem"
|
|
249
|
+
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
|
|
250
|
+
onClick={() => {
|
|
251
|
+
onDelete(element);
|
|
252
|
+
onClose();
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
<span>Delete</span>
|
|
256
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
|
|
257
|
+
</button>
|
|
258
|
+
<ClipMenuLinkItems part="delete" element={element} onClose={onClose} />
|
|
259
|
+
</div>
|
|
213
260
|
</div>,
|
|
214
261
|
document.body,
|
|
215
262
|
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
5
|
+
import { ClipPeakMarks } from "./ClipPeakMarks";
|
|
6
|
+
|
|
7
|
+
const harness = createHappyDomRootHarness();
|
|
8
|
+
|
|
9
|
+
async function render(url: string, bins: number[], gain: number) {
|
|
10
|
+
vi.stubGlobal(
|
|
11
|
+
"fetch",
|
|
12
|
+
vi.fn(async () => Response.json({ binSeconds: 1, bins })),
|
|
13
|
+
);
|
|
14
|
+
const host = document.createElement("div");
|
|
15
|
+
document.body.appendChild(host);
|
|
16
|
+
await act(async () => {
|
|
17
|
+
harness.mount(host).render(
|
|
18
|
+
<ClipPeakMarks peaksUrl={url} sourceWindow={{ mediaStart: 0, sourceSpan: 2 }} gain={gain}>
|
|
19
|
+
<span>wave</span>
|
|
20
|
+
</ClipPeakMarks>,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
await act(async () => {});
|
|
24
|
+
vi.unstubAllGlobals();
|
|
25
|
+
return host;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("ClipPeakMarks", () => {
|
|
29
|
+
it("paints red marks and the peak on a clip that redlines", async () => {
|
|
30
|
+
const host = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1);
|
|
31
|
+
expect(host.textContent).toContain("wave");
|
|
32
|
+
expect(host.querySelector("[data-testid=clip-peak-marks]")?.textContent).toContain(
|
|
33
|
+
"▲ peaks −0.2 dBFS",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("paints nothing on a quiet clip", async () => {
|
|
38
|
+
const host = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1);
|
|
39
|
+
expect(host.querySelector("[data-testid=clip-peak-marks]")).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
import { clipPeakRuns, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
|
|
3
|
+
|
|
4
|
+
const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
|
|
5
|
+
|
|
6
|
+
function loadPeakMap(url: string): Promise<PeakMap | null> {
|
|
7
|
+
const pending = peakMapRequests.get(url);
|
|
8
|
+
if (pending) return pending;
|
|
9
|
+
const request = fetch(url)
|
|
10
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
11
|
+
.then((body: unknown) => (isPeakMap(body) ? body : null))
|
|
12
|
+
.catch(() => null);
|
|
13
|
+
peakMapRequests.set(url, request);
|
|
14
|
+
return request;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const dbText = (db: number) => `${db < 0 ? "−" : "+"}${Math.abs(db).toFixed(1)} dBFS`;
|
|
18
|
+
|
|
19
|
+
/** A clip's waveform with red marks where it, at its own volume, reaches the export ceiling. */
|
|
20
|
+
export function ClipPeakMarks({
|
|
21
|
+
peaksUrl,
|
|
22
|
+
sourceWindow,
|
|
23
|
+
gain,
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
peaksUrl: string | undefined;
|
|
27
|
+
sourceWindow: ClipSourceWindow;
|
|
28
|
+
gain: number;
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
}) {
|
|
31
|
+
const [map, setMap] = useState<PeakMap | null>(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!peaksUrl) return;
|
|
34
|
+
let live = true;
|
|
35
|
+
void loadPeakMap(peaksUrl).then((loaded) => {
|
|
36
|
+
if (live) setMap(loaded);
|
|
37
|
+
});
|
|
38
|
+
return () => {
|
|
39
|
+
live = false;
|
|
40
|
+
};
|
|
41
|
+
}, [peaksUrl]);
|
|
42
|
+
const { runs, peakDbfs } = map
|
|
43
|
+
? clipPeakRuns(map, sourceWindow, gain)
|
|
44
|
+
: { runs: [], peakDbfs: null };
|
|
45
|
+
return (
|
|
46
|
+
<div className="relative h-full w-full">
|
|
47
|
+
{children}
|
|
48
|
+
{runs.length > 0 && peakDbfs !== null && (
|
|
49
|
+
<div
|
|
50
|
+
className="pointer-events-none absolute inset-0"
|
|
51
|
+
data-testid="clip-peak-marks"
|
|
52
|
+
title={`Peaks ${dbText(peakDbfs)} at this volume; export lowers the whole mix`}
|
|
53
|
+
>
|
|
54
|
+
{runs.map((run) => (
|
|
55
|
+
<div
|
|
56
|
+
key={run.from}
|
|
57
|
+
className="absolute inset-y-0 bg-red-500/80"
|
|
58
|
+
style={{
|
|
59
|
+
left: `${run.from * 100}%`,
|
|
60
|
+
width: `max(1px, ${(run.to - run.from) * 100}%)`,
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
))}
|
|
64
|
+
<span className="absolute top-0 right-1 font-mono text-[9px] leading-none text-red-400">
|
|
65
|
+
▲ peaks {dbText(peakDbfs)}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
5
|
+
import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { OutOfSyncBadge } from "./OutOfSyncBadge";
|
|
8
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
9
|
+
|
|
10
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
const harness = createHappyDomRootHarness();
|
|
13
|
+
const clip = (id: string, tag: string, start: number): TimelineElement => ({
|
|
14
|
+
id,
|
|
15
|
+
domId: id,
|
|
16
|
+
tag,
|
|
17
|
+
start,
|
|
18
|
+
duration: 4,
|
|
19
|
+
track: 0,
|
|
20
|
+
playbackStart: 0,
|
|
21
|
+
syncOrigin: "lk-1",
|
|
22
|
+
});
|
|
23
|
+
const video = clip("talk", "video", 1);
|
|
24
|
+
const lateAudio = clip("talk-audio", "audio", 1 + 51 / 30);
|
|
25
|
+
|
|
26
|
+
afterEach(() =>
|
|
27
|
+
useLinkedClipPreferences.setState({ syncIndicatorsVisible: true, compositionFps: 30 }),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
function render(el: TimelineElement, onLinkEdit = vi.fn()) {
|
|
31
|
+
usePlayerStore.getState().setElements([video, lateAudio]);
|
|
32
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
33
|
+
act(() =>
|
|
34
|
+
harness.mount(host).render(
|
|
35
|
+
<TimelineEditProvider value={{ onLinkEdit }}>
|
|
36
|
+
<OutOfSyncBadge el={el} />
|
|
37
|
+
</TimelineEditProvider>,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
const badge = () => document.querySelector('[data-testid="out-of-sync-badge"]');
|
|
41
|
+
return { badge, onLinkEdit };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("OutOfSyncBadge", () => {
|
|
45
|
+
it("shows the signed offset on both halves", () => {
|
|
46
|
+
useLinkedClipPreferences.setState({ compositionFps: 30 });
|
|
47
|
+
expect(render(lateAudio).badge()?.textContent).toBe("+1:21");
|
|
48
|
+
document.body.innerHTML = "";
|
|
49
|
+
expect(render(video).badge()?.textContent).toBe("-1:21");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("hides when the preference is off", () => {
|
|
53
|
+
useLinkedClipPreferences.setState({ syncIndicatorsVisible: false });
|
|
54
|
+
expect(render(lateAudio).badge()).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("opens Move / Slip into Sync from a click anywhere on the badge", () => {
|
|
58
|
+
const { badge, onLinkEdit } = render(lateAudio);
|
|
59
|
+
act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
60
|
+
const items = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'));
|
|
61
|
+
expect(items.map((item) => item.textContent)).toEqual(["Move into Sync", "Slip into Sync"]);
|
|
62
|
+
act(() => items[0]?.click());
|
|
63
|
+
expect(onLinkEdit).toHaveBeenCalledWith(
|
|
64
|
+
expect.objectContaining({ kind: "move-into-sync", element: lateAudio, start: 1 }),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("slips from a right-click, and the click never reaches the clip", () => {
|
|
69
|
+
const clipClick = vi.fn();
|
|
70
|
+
document.body.addEventListener("contextmenu", clipClick);
|
|
71
|
+
const { badge, onLinkEdit } = render(lateAudio);
|
|
72
|
+
act(() => badge()?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })));
|
|
73
|
+
expect(clipClick).not.toHaveBeenCalled();
|
|
74
|
+
const slip = document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')[1];
|
|
75
|
+
act(() => slip?.click());
|
|
76
|
+
const edit = onLinkEdit.mock.calls[0]?.[0];
|
|
77
|
+
expect(edit.kind).toBe("slip-into-sync");
|
|
78
|
+
expect(edit.mediaStart).toBeCloseTo(51 / 30);
|
|
79
|
+
document.body.removeEventListener("contextmenu", clipClick);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("flips the menu above the pointer near the bottom edge", () => {
|
|
83
|
+
const { badge } = render(lateAudio);
|
|
84
|
+
const y = window.innerHeight - 10;
|
|
85
|
+
act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true, clientY: y })));
|
|
86
|
+
const menu = document.querySelector<HTMLElement>('[role="menu"]');
|
|
87
|
+
const top = Number.parseFloat(menu?.style.top ?? "");
|
|
88
|
+
expect(top).toBeLessThan(y);
|
|
89
|
+
expect(top).toBeGreaterThanOrEqual(0);
|
|
90
|
+
});
|
|
91
|
+
});
|