@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,119 @@
|
|
|
1
|
+
import { memo, useCallback, useState, type SyntheticEvent } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { formatSyncOffset } from "@hyperframes/core/media-link";
|
|
4
|
+
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
5
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
6
|
+
import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
|
7
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
8
|
+
import { clipSyncState, type ClipSyncState } from "./clipSync";
|
|
9
|
+
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
10
|
+
|
|
11
|
+
const stop = (e: SyntheticEvent) => e.stopPropagation();
|
|
12
|
+
|
|
13
|
+
const MENU_ROW =
|
|
14
|
+
"block w-full px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800 disabled:opacity-40";
|
|
15
|
+
|
|
16
|
+
function SyncMenu({
|
|
17
|
+
x,
|
|
18
|
+
y,
|
|
19
|
+
el,
|
|
20
|
+
sync,
|
|
21
|
+
onClose,
|
|
22
|
+
}: {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
el: TimelineElement;
|
|
26
|
+
sync: ClipSyncState<TimelineElement>;
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
}) {
|
|
29
|
+
const menuRef = useContextMenuDismiss(onClose);
|
|
30
|
+
useMenuKeyboardNav(menuRef);
|
|
31
|
+
const { onLinkEdit } = useTimelineEditContextOptional();
|
|
32
|
+
const { moveStart, slipMediaStart } = sync;
|
|
33
|
+
const menuWidth = 170;
|
|
34
|
+
const menuHeight = 66;
|
|
35
|
+
const left = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
36
|
+
const top = y + menuHeight > window.innerHeight ? Math.max(0, y - menuHeight) : y;
|
|
37
|
+
return createPortal(
|
|
38
|
+
<div
|
|
39
|
+
ref={menuRef}
|
|
40
|
+
role="menu"
|
|
41
|
+
aria-label="Out of sync"
|
|
42
|
+
className="fixed z-200 min-w-[170px] rounded-sm border border-neutral-700 bg-neutral-900 py-1 shadow-lg"
|
|
43
|
+
style={{ left, top }}
|
|
44
|
+
>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
role="menuitem"
|
|
48
|
+
className={MENU_ROW}
|
|
49
|
+
disabled={!onLinkEdit || moveStart === null}
|
|
50
|
+
title={moveStart === null ? "Would move the clip before 0:00" : undefined}
|
|
51
|
+
onClick={() => {
|
|
52
|
+
if (moveStart !== null)
|
|
53
|
+
void onLinkEdit?.({ kind: "move-into-sync", element: el, start: moveStart });
|
|
54
|
+
onClose();
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
Move into Sync
|
|
58
|
+
</button>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
role="menuitem"
|
|
62
|
+
className={MENU_ROW}
|
|
63
|
+
disabled={!onLinkEdit || slipMediaStart === null}
|
|
64
|
+
title={slipMediaStart === null ? "Would slip before the start of the file" : undefined}
|
|
65
|
+
onClick={() => {
|
|
66
|
+
if (slipMediaStart !== null) {
|
|
67
|
+
void onLinkEdit?.({ kind: "slip-into-sync", element: el, mediaStart: slipMediaStart });
|
|
68
|
+
}
|
|
69
|
+
onClose();
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
Slip into Sync
|
|
73
|
+
</button>
|
|
74
|
+
</div>,
|
|
75
|
+
document.body,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Premiere's red out-of-sync number, at the start of each half of a source pair
|
|
81
|
+
* that drifted. The whole badge opens Move / Slip into Sync (Premiere's own hit
|
|
82
|
+
* target is a sliver at the badge edge, a known complaint).
|
|
83
|
+
*/
|
|
84
|
+
export const OutOfSyncBadge = memo(function OutOfSyncBadge({ el }: { el: TimelineElement }) {
|
|
85
|
+
const visible = useLinkedClipPreferences((s) => s.syncIndicatorsVisible);
|
|
86
|
+
const fps = useLinkedClipPreferences((s) => s.compositionFps);
|
|
87
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
88
|
+
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
|
89
|
+
const closeMenu = useCallback(() => setMenuAt(null), []);
|
|
90
|
+
const sync = visible ? clipSyncState(el, elements, fps) : null;
|
|
91
|
+
if (!sync) return null;
|
|
92
|
+
const label = formatSyncOffset(sync.frames, fps);
|
|
93
|
+
const partnerName = sync.partner.label || sync.partner.id;
|
|
94
|
+
const open = (e: React.MouseEvent) => {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
e.stopPropagation();
|
|
97
|
+
setMenuAt({ x: e.clientX, y: e.clientY });
|
|
98
|
+
};
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
<span
|
|
102
|
+
role="button"
|
|
103
|
+
tabIndex={-1}
|
|
104
|
+
aria-haspopup="menu"
|
|
105
|
+
aria-label={`Out of sync with ${partnerName} by ${label} frames`}
|
|
106
|
+
title={`Out of sync with ${partnerName} by ${label} — click for Move or Slip into Sync`}
|
|
107
|
+
data-testid="out-of-sync-badge"
|
|
108
|
+
className="absolute bottom-0.5 left-1 z-[7] cursor-pointer rounded-[3px] bg-red-600 px-1 font-mono text-[9px] leading-[14px] text-white tabular-nums"
|
|
109
|
+
onPointerDown={stop}
|
|
110
|
+
onDoubleClick={stop}
|
|
111
|
+
onClick={open}
|
|
112
|
+
onContextMenu={open}
|
|
113
|
+
>
|
|
114
|
+
{label}
|
|
115
|
+
</span>
|
|
116
|
+
{menuAt && <SyncMenu x={menuAt.x} y={menuAt.y} el={el} sync={sync} onClose={closeMenu} />}
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
});
|
|
@@ -105,6 +105,23 @@ describe("TimelineClip", () => {
|
|
|
105
105
|
expect(seen).toEqual([{ fadeIn: 1, fadeOut: 0.5, duration: 4 }, null]);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
it("gives both halves of a link group the same label colour, and unlinked clips none", () => {
|
|
109
|
+
const colorOf = (element: TimelineElement) => {
|
|
110
|
+
const { host, root } = renderClip({ element });
|
|
111
|
+
const clip = host.querySelector<HTMLElement>(".timeline-clip");
|
|
112
|
+
const color = clip?.getAttribute("data-link-color") ?? null;
|
|
113
|
+
expect(clip?.style.getPropertyValue("--clip-link-color") || null).toBe(color);
|
|
114
|
+
act(() => root.unmount());
|
|
115
|
+
return color;
|
|
116
|
+
};
|
|
117
|
+
const base = { start: 0, duration: 2, track: 0 };
|
|
118
|
+
const video = colorOf({ id: "v", tag: "video", link: "lk-3", ...base });
|
|
119
|
+
expect(video).not.toBeNull();
|
|
120
|
+
expect(colorOf({ id: "a", tag: "audio", link: "lk-3", ...base })).toBe(video);
|
|
121
|
+
expect(colorOf({ id: "b", tag: "audio", link: "lk-4", ...base })).not.toBe(video);
|
|
122
|
+
expect(colorOf({ id: "c", tag: "audio", ...base })).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
108
125
|
it("renders the clip label above custom content without showing default timecode", () => {
|
|
109
126
|
const { host, root } = renderClip({
|
|
110
127
|
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 1, track: 0 },
|
|
@@ -11,6 +11,9 @@ import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
|
11
11
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
12
12
|
import { ClipFadesContext, TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
|
|
13
13
|
import { rendersWaveform } from "./AudioWaveform";
|
|
14
|
+
import { ClipBadges } from "./ClipBadges";
|
|
15
|
+
import { linkLabelColor } from "./linkLabelColor";
|
|
16
|
+
import { OutOfSyncBadge } from "./OutOfSyncBadge";
|
|
14
17
|
|
|
15
18
|
interface TimelineClipProps {
|
|
16
19
|
el: TimelineElement;
|
|
@@ -82,6 +85,8 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
82
85
|
"--clip-border-active": theme.clipBorderActive,
|
|
83
86
|
"--clip-handle": theme.handleColor,
|
|
84
87
|
} as CSSProperties;
|
|
88
|
+
const linkColor = linkLabelColor(el.link);
|
|
89
|
+
if (linkColor) Object.assign(themeVariables, { "--clip-link-color": linkColor });
|
|
85
90
|
const isAudioClip = isAudioTimelineElement(el);
|
|
86
91
|
const hasFades = (isAudioClip || Boolean(el.hasAudio)) && !isGestureActor;
|
|
87
92
|
const fade = useClipFadeDraft(el);
|
|
@@ -123,6 +128,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
123
128
|
data-clip-start={el.start}
|
|
124
129
|
data-clip-end={el.start + el.duration}
|
|
125
130
|
data-clip-hidden={el.hidden ? "true" : undefined}
|
|
131
|
+
data-link-color={linkColor ?? undefined}
|
|
126
132
|
data-ladder={ladder}
|
|
127
133
|
data-active={isActive ? "" : undefined}
|
|
128
134
|
aria-hidden={isGestureActor ? "true" : undefined}
|
|
@@ -204,6 +210,8 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
204
210
|
</div>
|
|
205
211
|
)}
|
|
206
212
|
{showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
|
|
213
|
+
{showLabel && !isGestureActor && <ClipBadges el={el} />}
|
|
214
|
+
{!isGestureActor && el.syncOrigin && <OutOfSyncBadge el={el} />}
|
|
207
215
|
{showDefaultText && (
|
|
208
216
|
<span className="timeline-clip__timecode">
|
|
209
217
|
{startLabel}-{endLabel}s
|
|
@@ -181,13 +181,13 @@ describe("TimelineFxButton", () => {
|
|
|
181
181
|
<TimelineFxButton
|
|
182
182
|
variant="group-pointer"
|
|
183
183
|
clipCount={2}
|
|
184
|
-
refusal="
|
|
184
|
+
refusal="This track can't be grouped."
|
|
185
185
|
onGroupClips={onGroupClips}
|
|
186
186
|
/>,
|
|
187
187
|
);
|
|
188
188
|
act(() => byTextButton(host, "FX")?.click());
|
|
189
189
|
const dialog = document.querySelector('[role="dialog"]');
|
|
190
|
-
expect(dialog?.textContent).toContain("
|
|
190
|
+
expect(dialog?.textContent).toContain("This track can't be grouped");
|
|
191
191
|
expect(document.querySelector('input[aria-label="Group name"]')).toBeNull();
|
|
192
192
|
expect(
|
|
193
193
|
Array.from(document.body.querySelectorAll("button")).some((b) => b.textContent === "Group"),
|
|
@@ -869,6 +869,43 @@ describe("TimelineTrackHeader", () => {
|
|
|
869
869
|
act(() => view.root.unmount());
|
|
870
870
|
});
|
|
871
871
|
|
|
872
|
+
it("offers grouping on a track of audible videos, not on muted b-roll", () => {
|
|
873
|
+
const aRoll: TimelineElement = {
|
|
874
|
+
...VOICE,
|
|
875
|
+
id: "a-roll",
|
|
876
|
+
domId: "a-roll",
|
|
877
|
+
tag: "video",
|
|
878
|
+
hasAudio: true,
|
|
879
|
+
};
|
|
880
|
+
const aRoll2: TimelineElement = { ...aRoll, id: "a-roll-2", domId: "a-roll-2" };
|
|
881
|
+
const bRoll: TimelineElement = {
|
|
882
|
+
...aRoll,
|
|
883
|
+
id: "b-roll",
|
|
884
|
+
domId: "b-roll",
|
|
885
|
+
hasAudio: undefined,
|
|
886
|
+
};
|
|
887
|
+
const bRoll2: TimelineElement = { ...bRoll, id: "b-roll-2", domId: "b-roll-2" };
|
|
888
|
+
const opts = {
|
|
889
|
+
keyframeClip: aRoll,
|
|
890
|
+
trackElements: [aRoll, aRoll2],
|
|
891
|
+
clipCount: 2,
|
|
892
|
+
animations: [],
|
|
893
|
+
expanded: false,
|
|
894
|
+
};
|
|
895
|
+
const pointer = (host: HTMLElement) =>
|
|
896
|
+
host.querySelector<HTMLButtonElement>(
|
|
897
|
+
'button[aria-label="Effects — group these clips first"]',
|
|
898
|
+
);
|
|
899
|
+
const view = renderHeader(opts);
|
|
900
|
+
const button = pointer(view.host);
|
|
901
|
+
expect(button).not.toBeNull();
|
|
902
|
+
act(() => button?.click());
|
|
903
|
+
expect(document.body.textContent).not.toContain("can't be grouped");
|
|
904
|
+
view.rerender({ ...opts, keyframeClip: bRoll, trackElements: [bRoll, bRoll2] });
|
|
905
|
+
expect(pointer(view.host)).toBeNull();
|
|
906
|
+
act(() => view.root.unmount());
|
|
907
|
+
});
|
|
908
|
+
|
|
872
909
|
// The header is a 48px column of exactly TWO lines — what the row is, then
|
|
873
910
|
// what you can do to it. The group pointer used to render as a sibling of
|
|
874
911
|
// both, making a third: 17 + 24 + 24 + gaps in a 48px box, which
|
|
@@ -226,11 +226,9 @@ export function TimelineTrackHeader({
|
|
|
226
226
|
const singleAudioClip =
|
|
227
227
|
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
|
|
228
228
|
const isTrackGrouped = trackElements.some((el) => el.audioGroup);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const isVideoWithAudioTrack =
|
|
233
|
-
!isAudioTrack && trackElements.some((el) => el.tag.toLowerCase() === "video");
|
|
229
|
+
const audioBearingClips = isAudioTrack
|
|
230
|
+
? trackElements
|
|
231
|
+
: trackElements.filter((el) => el.hasAudio === true);
|
|
234
232
|
const writeClipFxChain = (
|
|
235
233
|
clip: TimelineElement,
|
|
236
234
|
next: HfAudioFxChain,
|
|
@@ -256,7 +254,7 @@ export function TimelineTrackHeader({
|
|
|
256
254
|
// UI showed the track as grouped. The button is withheld instead of acting on
|
|
257
255
|
// a subset, which is also why the carve path's loud guard cannot catch this:
|
|
258
256
|
// the unresolvable ids were filtered out before the call.
|
|
259
|
-
const groupableClipIds =
|
|
257
|
+
const groupableClipIds = audioBearingClips.map(runtimeAudioId);
|
|
260
258
|
const allGroupableClipIds = groupableClipIds.every((id): id is string => id !== null)
|
|
261
259
|
? groupableClipIds
|
|
262
260
|
: null;
|
|
@@ -342,25 +340,14 @@ export function TimelineTrackHeader({
|
|
|
342
340
|
onOpenRack={() => openClipFxRack(singleAudioClip)}
|
|
343
341
|
/>
|
|
344
342
|
)}
|
|
345
|
-
{showAudioEffects &&
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
// Groups are audio-only in v1 (§1.4). A video track showing no
|
|
354
|
-
// button at all is the silent limit §5 forbids, so it gets the
|
|
355
|
-
// button and a reason instead.
|
|
356
|
-
refusal={
|
|
357
|
-
isAudioTrack
|
|
358
|
-
? undefined
|
|
359
|
-
: "Video audio can't be grouped yet — only audio clips can join a group."
|
|
360
|
-
}
|
|
361
|
-
onGroupClips={groupUngroupedClips}
|
|
362
|
-
/>
|
|
363
|
-
)}
|
|
343
|
+
{showAudioEffects && clipCount > 1 && !isTrackGrouped && canGroupWholeTrack && (
|
|
344
|
+
<TimelineFxButton
|
|
345
|
+
variant="group-pointer"
|
|
346
|
+
clipCount={audioBearingClips.length}
|
|
347
|
+
defaultLabel={trackLabel}
|
|
348
|
+
onGroupClips={groupUngroupedClips}
|
|
349
|
+
/>
|
|
350
|
+
)}
|
|
364
351
|
{/* The lane disclosure, on the row's own layout rather than by
|
|
365
352
|
swapping it for a keyframe-layer row. */}
|
|
366
353
|
{disclosable && (
|
|
@@ -1,34 +1,231 @@
|
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
3
|
+
import {
|
|
4
|
+
audioPillFlags,
|
|
5
|
+
expandToLinkedMembers,
|
|
6
|
+
linkedGestureKeys,
|
|
7
|
+
linkedMembersOf,
|
|
8
|
+
mediaAssetIdentity,
|
|
9
|
+
} from "./audioClipLink";
|
|
10
|
+
import { createTimelineElementFromManifestClip, parseTimelineFromDOM } from "../lib/timelineDOM";
|
|
11
|
+
|
|
12
|
+
const video = { id: "talk", link: "lk-1" };
|
|
13
|
+
const audio = { id: "talk-audio", link: "lk-1" };
|
|
14
|
+
const other = { id: "music", link: "lk-2" };
|
|
15
|
+
const otherAudio = { id: "music-audio", link: "lk-2" };
|
|
16
|
+
const plain = { id: "title" };
|
|
17
|
+
const elements = [video, audio, other, otherAudio, plain];
|
|
18
|
+
|
|
19
|
+
describe("audioPillFlags", () => {
|
|
20
|
+
it("greys a hidden clip and a muted group", () => {
|
|
21
|
+
expect(audioPillFlags({ hidden: true }).muted).toBe(true);
|
|
22
|
+
expect(audioPillFlags({ audioGroupHidden: true }).muted).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("mediaAssetIdentity", () => {
|
|
27
|
+
it("tells same-named files in different folders apart", () => {
|
|
28
|
+
expect(mediaAssetIdentity({ src: "assets/one/talk.mp4" })).not.toBe(
|
|
29
|
+
mediaAssetIdentity({ src: "assets/two/talk.mp4" }),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("resolves a src against its own source file and drops query and hash", () => {
|
|
34
|
+
expect(mediaAssetIdentity({ src: "../assets/talk.mp4?v=2", sourceFile: "scenes/a.html" })).toBe(
|
|
35
|
+
mediaAssetIdentity({ src: "./assets/talk.mp4#t=1" }),
|
|
36
|
+
);
|
|
37
|
+
expect(mediaAssetIdentity({ src: "assets/City%20Ride.mp4" })).toBe(
|
|
38
|
+
mediaAssetIdentity({ src: "assets/City Ride.mp4" }),
|
|
39
|
+
);
|
|
40
|
+
expect(mediaAssetIdentity({ src: "" })).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps a remote url's query, since it can select a different file", () => {
|
|
44
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1" })).not.toBe(
|
|
45
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=2" }),
|
|
46
|
+
);
|
|
47
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a%26x=b" })).not.toBe(
|
|
48
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a&x=b" }),
|
|
49
|
+
);
|
|
50
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4%3Fclip=a" })).not.toBe(
|
|
51
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a" }),
|
|
52
|
+
);
|
|
53
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1#t=3" })).toBe(
|
|
54
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1" }),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("expandToLinkedMembers", () => {
|
|
60
|
+
it("adds every partner of a linked clip", () => {
|
|
61
|
+
expect(expandToLinkedMembers(["talk"], elements)).toEqual(new Set(["talk", "talk-audio"]));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("leaves unlinked clips alone and does not pull in other groups", () => {
|
|
65
|
+
expect(expandToLinkedMembers(["title"], elements)).toEqual(new Set(["title"]));
|
|
66
|
+
expect(expandToLinkedMembers(["talk-audio", "title"], elements)).toEqual(
|
|
67
|
+
new Set(["talk-audio", "talk", "title"]),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("prefers the store key over the id", () => {
|
|
72
|
+
const keyed = [
|
|
73
|
+
{ id: "a", key: "k-a", link: "lk-9" },
|
|
74
|
+
{ id: "b", key: "k-b", link: "lk-9" },
|
|
75
|
+
];
|
|
76
|
+
expect(expandToLinkedMembers(["k-a"], keyed)).toEqual(new Set(["k-a", "k-b"]));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("link groups stay inside their composition", () => {
|
|
81
|
+
const root = [
|
|
82
|
+
{ id: "v", link: "lk-1", sourceFile: undefined },
|
|
83
|
+
{ id: "a", link: "lk-1", sourceFile: undefined },
|
|
84
|
+
];
|
|
85
|
+
const child = [
|
|
86
|
+
{ id: "cv", key: "child.html#cv", link: "lk-1", sourceFile: "child.html" },
|
|
87
|
+
{ id: "ca", key: "child.html#ca", link: "lk-1", sourceFile: "child.html" },
|
|
88
|
+
];
|
|
89
|
+
const all = [...root, ...child];
|
|
90
|
+
|
|
91
|
+
it("does not pull a child file's same-id link into the root group", () => {
|
|
92
|
+
expect(linkedMembersOf(root[0] ?? video, all).map((el) => el.id)).toEqual(["v", "a"]);
|
|
93
|
+
expect(expandToLinkedMembers(["child.html#cv"], all)).toEqual(
|
|
94
|
+
new Set(["child.html#cv", "child.html#ca"]),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const media = (id: string, tag: string, start: number) =>
|
|
99
|
+
`<${tag} id="${id}" class="clip" src="${id}.mp4" data-link="lk-1" data-start="${start}" data-duration="4"></${tag}>`;
|
|
100
|
+
const inlineDoc = () => {
|
|
101
|
+
const doc = document.implementation.createHTMLDocument();
|
|
102
|
+
doc.body.innerHTML = `<div data-composition-id="main" data-duration="20">
|
|
103
|
+
${media("v", "video", 0)}${media("a", "audio", 0)}
|
|
104
|
+
<div id="child" data-composition-id="child" data-start="0" data-duration="10">
|
|
105
|
+
${media("cv", "video", 5)}${media("ca", "audio", 5)}
|
|
106
|
+
</div>
|
|
107
|
+
</div>`;
|
|
108
|
+
return doc;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
it("does not pull an inline composition's same-id link into the root group", () => {
|
|
112
|
+
const rows = parseTimelineFromDOM(inlineDoc(), 20).filter((row) => row.link);
|
|
113
|
+
const byId = (id: string) => rows.find((row) => row.domId === id) ?? video;
|
|
114
|
+
expect(linkedMembersOf(byId("v"), rows).map((row) => row.domId)).toEqual(["v", "a"]);
|
|
115
|
+
expect(linkedMembersOf(byId("cv"), rows).map((row) => row.domId)).toEqual(["cv", "ca"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("keeps the inline composition apart on rows built from the runtime manifest", () => {
|
|
119
|
+
const doc = inlineDoc();
|
|
120
|
+
const rows = ["v", "a", "cv", "ca"].map((id, fallbackIndex) =>
|
|
121
|
+
createTimelineElementFromManifestClip({
|
|
122
|
+
clip: {
|
|
123
|
+
...{ id, label: id, start: 0, duration: 4, track: 0, kind: "video", tagName: "video" },
|
|
124
|
+
...{
|
|
125
|
+
compositionId: null,
|
|
126
|
+
parentCompositionId: null,
|
|
127
|
+
compositionSrc: null,
|
|
128
|
+
assetUrl: null,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
fallbackIndex,
|
|
132
|
+
doc,
|
|
133
|
+
hostEl: doc.getElementById(id),
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
const [rootVideo] = rows;
|
|
137
|
+
expect(linkedMembersOf(rootVideo ?? video, rows).map((row) => row.domId)).toEqual(["v", "a"]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("linkedMembersOf", () => {
|
|
142
|
+
it("returns the group, or the clip alone when unlinked", () => {
|
|
143
|
+
expect(linkedMembersOf(video, elements).map((el) => el.id)).toEqual(["talk", "talk-audio"]);
|
|
144
|
+
expect(linkedMembersOf(plain, elements)).toEqual([plain]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("linkedGestureKeys", () => {
|
|
149
|
+
it("drags an unselected linked clip with its partner", () => {
|
|
150
|
+
expect(linkedGestureKeys(new Set(["title"]), video, elements, false)).toEqual(
|
|
151
|
+
new Set(["talk", "talk-audio"]),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps a selection that holds the grabbed clip and adds partners", () => {
|
|
156
|
+
expect(linkedGestureKeys(new Set(["talk", "title"]), video, elements, false)).toEqual(
|
|
157
|
+
new Set(["talk", "talk-audio", "title"]),
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("Linked Selection off edits the grabbed clip alone without Alt", () => {
|
|
162
|
+
expect(linkedGestureKeys(new Set(["talk"]), video, elements, false, false)).toEqual(
|
|
163
|
+
new Set(["talk"]),
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("Alt edits the grabbed clip alone", () => {
|
|
168
|
+
expect(linkedGestureKeys(new Set(["talk", "talk-audio"]), video, elements, true)).toEqual(
|
|
169
|
+
new Set(["talk"]),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("selectClipWithLinks", () => {
|
|
175
|
+
const linkedPair = [
|
|
176
|
+
{ id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
|
|
177
|
+
{ id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
|
|
178
|
+
{ id: "title", tag: "div", start: 0, duration: 4, track: 2 },
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
async function store() {
|
|
182
|
+
const { usePlayerStore } = await import("../store/playerStore");
|
|
183
|
+
const { selectClipWithLinks } = await import("./timelineLinkSelection");
|
|
184
|
+
usePlayerStore.getState().setElements(linkedPair);
|
|
185
|
+
const click = (key: string, alt = false) =>
|
|
186
|
+
selectClipWithLinks(key, alt, usePlayerStore.getState().setSelectedElementId);
|
|
187
|
+
return { usePlayerStore, click };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it("a click selects the whole pair; Alt-click then narrows to one member", async () => {
|
|
191
|
+
const { usePlayerStore, click } = await store();
|
|
192
|
+
click("talk");
|
|
193
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk", "talk-audio"]));
|
|
194
|
+
click("talk-audio", true);
|
|
195
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk-audio"]));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("a plain click collapses a larger selection to the clicked clip's link group", async () => {
|
|
199
|
+
const { usePlayerStore, click } = await store();
|
|
200
|
+
usePlayerStore.getState().setSelection(["talk", "talk-audio", "title"], "title");
|
|
201
|
+
click("talk");
|
|
202
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk", "talk-audio"]));
|
|
203
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("talk");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("Linked Selection off", () => {
|
|
208
|
+
const linkedPair = [
|
|
209
|
+
{ id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
|
|
210
|
+
{ id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
it("expands and lists nothing past the clip itself", () => {
|
|
214
|
+
expect(expandToLinkedMembers(["talk"], linkedPair, false)).toEqual(new Set(["talk"]));
|
|
215
|
+
expect(linkedMembersOf(linkedPair[0], linkedPair, false)).toEqual([linkedPair[0]]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("a click selects only the clicked clip", async () => {
|
|
219
|
+
const { usePlayerStore } = await import("../store/playerStore");
|
|
220
|
+
const { selectClipWithLinks } = await import("./timelineLinkSelection");
|
|
221
|
+
const { useLinkedClipPreferences } = await import("../../utils/linkedClipPreferences");
|
|
222
|
+
usePlayerStore.getState().setElements(linkedPair);
|
|
223
|
+
useLinkedClipPreferences.getState().setLinkedSelection(false);
|
|
224
|
+
try {
|
|
225
|
+
selectClipWithLinks("talk", false, usePlayerStore.getState().setSelectedElementId);
|
|
226
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk"]));
|
|
227
|
+
} finally {
|
|
228
|
+
useLinkedClipPreferences.getState().setLinkedSelection(true);
|
|
229
|
+
}
|
|
33
230
|
});
|
|
34
231
|
});
|