@hyperframes/studio 0.8.109 → 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-CJYtikAy.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-HTFVHUJ4.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CMSgrT2F.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-CYP6dMd7.js → index-Co6W8IVk.js} +205 -204
- package/dist/index.d.ts +21 -6
- package/dist/index.html +2 -2
- package/dist/index.js +5351 -4755
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- 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/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaLinkEdits.test.ts +19 -6
- package/src/components/editor/mediaLinkEdits.ts +34 -13
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +9 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
- package/src/hooks/timelineLinkEditPlan.ts +6 -1
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useRenderClipContent.ts +3 -5
- package/src/hooks/useTimelineEditing.ts +13 -0
- package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
- package/src/hooks/useTimelineLinkEditing.ts +5 -0
- package/src/index.ts +4 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- 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 +1 -1
- package/src/player/components/ClipContextMenu.tsx +1 -1
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +41 -0
- package/src/player/components/TimelineClip.tsx +3 -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 +60 -0
- package/src/player/components/audioClipLink.ts +77 -0
- 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 +16 -8
- package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
- package/src/player/components/clipMenuAudioItems.tsx +16 -37
- package/src/player/components/clipMenuLinkItems.test.ts +84 -0
- package/src/player/components/clipMenuLinkItems.tsx +54 -9
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +79 -6
- 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/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +6 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipGestureHandlers.ts +26 -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 +20 -2
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipDrag.ts +12 -5
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +0 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
|
@@ -1,18 +1,10 @@
|
|
|
1
|
-
import { formatAudioGain } from "@hyperframes/core/audio-gain";
|
|
2
1
|
import type { TimelineElement } from "../store/timelineElement";
|
|
3
2
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
4
3
|
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
5
4
|
import { usePlayerStore } from "../store/playerStore";
|
|
6
5
|
import { useLivePreviewIframe } from "../store/previewIframeStore";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
normalizeToastText,
|
|
10
|
-
requestNormalizePlan,
|
|
11
|
-
throwUnlessSaved,
|
|
12
|
-
TimelineSaveError,
|
|
13
|
-
VOLUME_LANE_REFUSAL,
|
|
14
|
-
volumeLaneOwnsGain,
|
|
15
|
-
} from "./clipMenuNormalize";
|
|
6
|
+
import { clipHasSound, throwUnlessSaved, TimelineSaveError } from "./clipMenuNormalize";
|
|
7
|
+
import { openAudioGainDialog } from "./audioGainDialogStore";
|
|
16
8
|
import { offersDuck, readBedCarve, setDuckUnderVoice, type DuckOutcome } from "./clipMenuDuck";
|
|
17
9
|
|
|
18
10
|
const ITEM_CLASS =
|
|
@@ -25,13 +17,13 @@ const DUCK_TOAST: Record<DuckOutcome, string> = {
|
|
|
25
17
|
aborted: "Could not group the voices to duck under.",
|
|
26
18
|
};
|
|
27
19
|
|
|
28
|
-
/**
|
|
20
|
+
/** Audio Gain… (opens the G dialog) or Duck under voice (toggle), placed separately in the sound group. */
|
|
29
21
|
export function ClipMenuAudioItems({
|
|
30
22
|
part,
|
|
31
23
|
element,
|
|
32
24
|
onClose,
|
|
33
25
|
}: {
|
|
34
|
-
part: "
|
|
26
|
+
part: "gain" | "duck";
|
|
35
27
|
element: TimelineElement;
|
|
36
28
|
onClose: () => void;
|
|
37
29
|
}) {
|
|
@@ -48,28 +40,6 @@ export function ClipMenuAudioItems({
|
|
|
48
40
|
return null;
|
|
49
41
|
}
|
|
50
42
|
|
|
51
|
-
const normalize = async () => {
|
|
52
|
-
onClose();
|
|
53
|
-
if (volumeLaneOwnsGain(element)) {
|
|
54
|
-
showToast(VOLUME_LANE_REFUSAL, "error");
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
try {
|
|
58
|
-
const plan = await requestNormalizePlan(projectId, element);
|
|
59
|
-
throwUnlessSaved(
|
|
60
|
-
await onSetElementAttributeQuiet(
|
|
61
|
-
element,
|
|
62
|
-
"data-volume",
|
|
63
|
-
formatAudioGain(plan.volume),
|
|
64
|
-
"Normalize loudness",
|
|
65
|
-
),
|
|
66
|
-
);
|
|
67
|
-
showToast(normalizeToastText(plan), "info");
|
|
68
|
-
} catch (error) {
|
|
69
|
-
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
|
|
73
43
|
const toggleDuck = async () => {
|
|
74
44
|
onClose();
|
|
75
45
|
if (!doc || !bed) return;
|
|
@@ -93,10 +63,19 @@ export function ClipMenuAudioItems({
|
|
|
93
63
|
showToast(DUCK_TOAST[outcome], outcome === "aborted" ? "error" : "info");
|
|
94
64
|
};
|
|
95
65
|
|
|
96
|
-
if (part === "
|
|
66
|
+
if (part === "gain") {
|
|
97
67
|
return (
|
|
98
|
-
<button
|
|
99
|
-
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
role="menuitem"
|
|
71
|
+
className={ITEM_CLASS}
|
|
72
|
+
onClick={() => {
|
|
73
|
+
onClose();
|
|
74
|
+
openAudioGainDialog(element);
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<span>Audio Gain…</span>
|
|
78
|
+
<span className="text-neutral-500 text-[10px] ml-3">G</span>
|
|
100
79
|
</button>
|
|
101
80
|
);
|
|
102
81
|
}
|
|
@@ -48,6 +48,51 @@ describe("resolveLinkMenuItems", () => {
|
|
|
48
48
|
expect(labels(audio, [video, audio], ["talk", "talk-audio"])[0]).toBe("Link to video");
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
it("offers Link to the lone same-file partner on another track when one clip is right-clicked", () => {
|
|
52
|
+
const video = clip("talk", "video", { track: 0 });
|
|
53
|
+
const audio = clip("talk-audio", "audio", { track: 1, start: 2 });
|
|
54
|
+
expect(labels(audio, [video, audio], ["talk-audio"])[0]).toBe("Link to video");
|
|
55
|
+
expect(labels(video, [video, audio])[0]).toBe("Link to audio");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("dispatches the convenience link with the clip and its partner", () => {
|
|
59
|
+
const onLinkEdit = vi.fn();
|
|
60
|
+
const video = clip("talk", "video", { track: 0 });
|
|
61
|
+
const audio = clip("talk-audio", "audio", { track: 1 });
|
|
62
|
+
const [link] = resolveLinkMenuItems({
|
|
63
|
+
element: audio,
|
|
64
|
+
elements: [video, audio],
|
|
65
|
+
selectedKeys: new Set(["talk-audio"]),
|
|
66
|
+
onLinkEdit,
|
|
67
|
+
});
|
|
68
|
+
link?.run();
|
|
69
|
+
expect(onLinkEdit).toHaveBeenCalledWith({ kind: "link", elements: [audio, video] });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("offers no convenience Link when the partner is ambiguous, linked, another file or the same track", () => {
|
|
73
|
+
const audio = clip("talk-audio", "audio", { track: 1 });
|
|
74
|
+
const video = clip("talk", "video", { track: 0 });
|
|
75
|
+
const twin = clip("talk-2", "video", { track: 2 });
|
|
76
|
+
expect(labels(audio, [video, twin, audio], ["talk-audio"])).not.toContain("Link to video");
|
|
77
|
+
const linked = clip("talk", "video", { track: 0, link: "lk-9" });
|
|
78
|
+
expect(labels(audio, [linked, audio], ["talk-audio"])).not.toContain("Link to video");
|
|
79
|
+
const other = clip("other", "video", { track: 0, src: "other.mp4" });
|
|
80
|
+
expect(labels(audio, [other, audio], ["talk-audio"])).not.toContain("Link to video");
|
|
81
|
+
const sameTrack = clip("talk", "video", { track: 1 });
|
|
82
|
+
expect(labels(audio, [sameTrack, audio], ["talk-audio"])).not.toContain("Link to video");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("offers no convenience Link while other clips are also selected", () => {
|
|
86
|
+
const audio = clip("talk-audio", "audio", { track: 1 });
|
|
87
|
+
const video = clip("talk", "video", { track: 0 });
|
|
88
|
+
const title = clip("title", "div", { track: 2 });
|
|
89
|
+
expect(
|
|
90
|
+
labels(audio, [video, audio, title], ["talk-audio", "title"]).some((l) =>
|
|
91
|
+
l.startsWith("Link"),
|
|
92
|
+
),
|
|
93
|
+
).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
51
96
|
it("names the audio partner when unlinking from the video side", () => {
|
|
52
97
|
const video = clip("talk", "video", { muted: true, link: "lk-1" });
|
|
53
98
|
const audio = clip("talk-audio", "audio", { link: "lk-1" });
|
|
@@ -87,4 +132,43 @@ describe("resolveLinkMenuItems", () => {
|
|
|
87
132
|
})[0]?.run();
|
|
88
133
|
expect(onLinkEdit).toHaveBeenCalledWith({ kind: "detach", element: talk });
|
|
89
134
|
});
|
|
135
|
+
|
|
136
|
+
it("offers Link for a trimmed audio and its video (timing does not matter)", () => {
|
|
137
|
+
const video = clip("talk", "video", { muted: true });
|
|
138
|
+
const audio = clip("talk-audio", "audio", { start: 1, duration: 2, playbackStart: 1 });
|
|
139
|
+
expect(labels(video, [video, audio], ["talk", "talk-audio"])[0]).toBe("Link to audio");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("offers Link for a pair of different files", () => {
|
|
143
|
+
const video = clip("talk", "video", { muted: true });
|
|
144
|
+
const music = clip("bgm", "audio", { src: "bgm.mp3" });
|
|
145
|
+
expect(labels(music, [video, music], ["talk", "bgm"])).toEqual(["Link to video"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("offers no Link when one selected clip is already linked elsewhere", () => {
|
|
149
|
+
const video = clip("talk", "video", { muted: true, link: "lk-9" });
|
|
150
|
+
const audio = clip("bgm", "audio", { src: "bgm.mp3" });
|
|
151
|
+
const partner = clip("talk-audio", "audio", { link: "lk-9" });
|
|
152
|
+
expect(labels(audio, [video, audio, partner], ["talk", "bgm"])).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("disables Merge back with a tooltip while the pair is out of sync", () => {
|
|
156
|
+
const video = clip("talk", "video", { muted: true, link: "lk-1" });
|
|
157
|
+
const audio = clip("talk-audio", "audio", { link: "lk-1", start: 0.5 });
|
|
158
|
+
const merge = resolveLinkMenuItems({
|
|
159
|
+
element: audio,
|
|
160
|
+
elements: [video, audio],
|
|
161
|
+
selectedKeys: new Set(),
|
|
162
|
+
onLinkEdit: vi.fn(),
|
|
163
|
+
}).find((item) => item.label === "Merge audio back into video");
|
|
164
|
+
expect(merge?.disabledReason).toBe("Move into Sync first");
|
|
165
|
+
const synced = clip("talk-audio", "audio", { link: "lk-1" });
|
|
166
|
+
const enabled = resolveLinkMenuItems({
|
|
167
|
+
element: synced,
|
|
168
|
+
elements: [video, synced],
|
|
169
|
+
selectedKeys: new Set(),
|
|
170
|
+
onLinkEdit: vi.fn(),
|
|
171
|
+
}).find((item) => item.label === "Merge audio back into video");
|
|
172
|
+
expect(enabled?.disabledReason).toBeUndefined();
|
|
173
|
+
});
|
|
90
174
|
});
|
|
@@ -2,12 +2,19 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
|
2
2
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
3
3
|
import type { TimelineLinkEdit } from "./timelineCallbacks";
|
|
4
4
|
import { linkedMembersOf } from "./audioClipLink";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
canDetachAudio,
|
|
7
|
+
canLinkPair,
|
|
8
|
+
findMergePair,
|
|
9
|
+
isPairInSync,
|
|
10
|
+
sharesSourceFile,
|
|
11
|
+
} from "../../components/editor/mediaLinkEdits";
|
|
6
12
|
|
|
7
13
|
interface LinkMenuItem {
|
|
8
14
|
label: string;
|
|
9
15
|
shortcut?: string;
|
|
10
16
|
destructive?: boolean;
|
|
17
|
+
disabledReason?: string;
|
|
11
18
|
run: () => void;
|
|
12
19
|
}
|
|
13
20
|
|
|
@@ -15,6 +22,22 @@ const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
|
15
22
|
|
|
16
23
|
const tagOf = (el: TimelineElement) => el.tag.trim().toLowerCase();
|
|
17
24
|
|
|
25
|
+
function loneSameFilePartner(
|
|
26
|
+
element: TimelineElement,
|
|
27
|
+
elements: readonly TimelineElement[],
|
|
28
|
+
): TimelineElement | null {
|
|
29
|
+
const opposite = { video: "audio", audio: "video" }[tagOf(element)];
|
|
30
|
+
if (!opposite || element.link) return null;
|
|
31
|
+
const candidates = elements.filter(
|
|
32
|
+
(el) =>
|
|
33
|
+
!el.link &&
|
|
34
|
+
tagOf(el) === opposite &&
|
|
35
|
+
el.track !== element.track &&
|
|
36
|
+
sharesSourceFile([element, el]),
|
|
37
|
+
);
|
|
38
|
+
return candidates.length === 1 ? (candidates[0] ?? null) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
18
41
|
function partnerSuffix(element: TimelineElement, others: readonly TimelineElement[]): string {
|
|
19
42
|
const partners = others.filter((el) => keyOf(el) !== keyOf(element));
|
|
20
43
|
const [partner] = partners;
|
|
@@ -23,6 +46,26 @@ function partnerSuffix(element: TimelineElement, others: readonly TimelineElemen
|
|
|
23
46
|
return pair === "audio+video" ? ` ${tagOf(partner)}` : "";
|
|
24
47
|
}
|
|
25
48
|
|
|
49
|
+
function linkItem(
|
|
50
|
+
element: TimelineElement,
|
|
51
|
+
elements: readonly TimelineElement[],
|
|
52
|
+
selected: TimelineElement[],
|
|
53
|
+
selectedKeys: ReadonlySet<string>,
|
|
54
|
+
onLinkEdit: (edit: TimelineLinkEdit) => unknown,
|
|
55
|
+
): LinkMenuItem | null {
|
|
56
|
+
const selectedPair = canLinkPair(selected) && selectedKeys.has(keyOf(element));
|
|
57
|
+
const soleSelection = selected.every((el) => keyOf(el) === keyOf(element));
|
|
58
|
+
const partner = !selectedPair && soleSelection ? loneSameFilePartner(element, elements) : null;
|
|
59
|
+
const linkPair = selectedPair ? selected : partner ? [element, partner] : null;
|
|
60
|
+
if (!linkPair) return null;
|
|
61
|
+
const suffix = partnerSuffix(element, linkPair);
|
|
62
|
+
return {
|
|
63
|
+
label: `Link${suffix && ` to${suffix}`}`,
|
|
64
|
+
...(selectedPair ? { shortcut: "⌘L" } : {}),
|
|
65
|
+
run: () => onLinkEdit({ kind: "link", elements: linkPair }),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
26
69
|
/** The link-model items for a clip, in wireframe order (detach · unlink/link · merge · delete-one). */
|
|
27
70
|
export function resolveLinkMenuItems(input: {
|
|
28
71
|
element: TimelineElement;
|
|
@@ -50,17 +93,15 @@ export function resolveLinkMenuItems(input: {
|
|
|
50
93
|
shortcut: "⌘L",
|
|
51
94
|
run: () => onLinkEdit({ kind: "unlink", elements: members }),
|
|
52
95
|
});
|
|
53
|
-
} else
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
shortcut: "⌘L",
|
|
57
|
-
run: () => onLinkEdit({ kind: "link", elements: selected }),
|
|
58
|
-
});
|
|
96
|
+
} else {
|
|
97
|
+
const link = linkItem(element, elements, selected, selectedKeys, onLinkEdit);
|
|
98
|
+
if (link) items.push(link);
|
|
59
99
|
}
|
|
60
100
|
const pair = findMergePair(element, elements);
|
|
61
101
|
if (pair) {
|
|
62
102
|
items.push({
|
|
63
103
|
label: "Merge audio back into video",
|
|
104
|
+
disabledReason: isPairInSync(pair.video, pair.audio) ? undefined : "Move into Sync first",
|
|
64
105
|
run: () => onLinkEdit({ kind: "merge", ...pair }),
|
|
65
106
|
});
|
|
66
107
|
}
|
|
@@ -102,8 +143,12 @@ export function ClipMenuLinkItems({
|
|
|
102
143
|
key={item.label}
|
|
103
144
|
type="button"
|
|
104
145
|
role="menuitem"
|
|
105
|
-
|
|
106
|
-
|
|
146
|
+
disabled={item.disabledReason !== undefined}
|
|
147
|
+
title={item.disabledReason}
|
|
148
|
+
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden ${
|
|
149
|
+
item.disabledReason !== undefined
|
|
150
|
+
? "text-neutral-600 cursor-not-allowed"
|
|
151
|
+
: `cursor-pointer hover:bg-neutral-800 focus-visible:bg-neutral-800 ${item.destructive ? "text-red-400" : "text-neutral-300"}`
|
|
107
152
|
}`}
|
|
108
153
|
onClick={() => {
|
|
109
154
|
item.run();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { TimelineElement } from "../store/playerStore";
|
|
7
7
|
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
8
8
|
import { useCropPresetBarStore } from "../../components/editor/cropPresetStore";
|
|
@@ -15,7 +15,10 @@ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
|
15
15
|
|
|
16
16
|
let root: Root | null = null;
|
|
17
17
|
|
|
18
|
+
beforeEach(() => vi.useFakeTimers());
|
|
19
|
+
|
|
18
20
|
afterEach(() => {
|
|
21
|
+
vi.useRealTimers();
|
|
19
22
|
act(() => root?.unmount());
|
|
20
23
|
root = null;
|
|
21
24
|
document.body.innerHTML = "";
|
|
@@ -31,7 +34,14 @@ const video: TimelineElement = {
|
|
|
31
34
|
hasAudio: true,
|
|
32
35
|
};
|
|
33
36
|
|
|
34
|
-
function renderItems(
|
|
37
|
+
function renderItems(
|
|
38
|
+
group: ClipMenuToolGroup,
|
|
39
|
+
element: TimelineElement,
|
|
40
|
+
currentTime = 2,
|
|
41
|
+
withLive = true,
|
|
42
|
+
) {
|
|
43
|
+
const setLive = vi.fn((_el: TimelineElement, _attr: string, _value: string | null) => undefined);
|
|
44
|
+
const revertLive = vi.fn((_el: TimelineElement, _attr: string) => undefined);
|
|
35
45
|
const setQuiet = vi.fn(
|
|
36
46
|
async (_el: TimelineElement, _attr: string, _value: string | null, _label: string) => undefined,
|
|
37
47
|
);
|
|
@@ -42,7 +52,15 @@ function renderItems(group: ClipMenuToolGroup, element: TimelineElement, current
|
|
|
42
52
|
root = createRoot(host);
|
|
43
53
|
act(() => {
|
|
44
54
|
root?.render(
|
|
45
|
-
<TimelineEditProvider
|
|
55
|
+
<TimelineEditProvider
|
|
56
|
+
value={{
|
|
57
|
+
onSetElementAttributeQuiet: setQuiet,
|
|
58
|
+
onFreezeFrame: freeze,
|
|
59
|
+
...(withLive
|
|
60
|
+
? { onSetElementAttributeLive: setLive, onRevertElementAttributeLive: revertLive }
|
|
61
|
+
: {}),
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
46
64
|
<ClipMenuToolItems
|
|
47
65
|
group={group}
|
|
48
66
|
element={element}
|
|
@@ -52,7 +70,7 @@ function renderItems(group: ClipMenuToolGroup, element: TimelineElement, current
|
|
|
52
70
|
</TimelineEditProvider>,
|
|
53
71
|
);
|
|
54
72
|
});
|
|
55
|
-
return { setQuiet, onClose, freeze };
|
|
73
|
+
return { setQuiet, onClose, freeze, setLive, revertLive };
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
function openSubmenu(label: string) {
|
|
@@ -136,4 +154,112 @@ describe("ClipMenuToolItems", () => {
|
|
|
136
154
|
renderItems("time", { ...video, tag: "img" });
|
|
137
155
|
expect(document.body.textContent).toBe("");
|
|
138
156
|
});
|
|
157
|
+
|
|
158
|
+
describe("Look hover preview", () => {
|
|
159
|
+
function option(text: string) {
|
|
160
|
+
const found = Array.from(
|
|
161
|
+
document.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
|
|
162
|
+
).find((button) => button.textContent?.endsWith(text));
|
|
163
|
+
if (!found) throw new Error(`no option ${text}`);
|
|
164
|
+
return found;
|
|
165
|
+
}
|
|
166
|
+
const enter = (text: string) =>
|
|
167
|
+
option(text).dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
|
168
|
+
const hover = (text: string) => {
|
|
169
|
+
act(() => {
|
|
170
|
+
enter(text);
|
|
171
|
+
vi.advanceTimersByTime(100);
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
const leave = () => {
|
|
175
|
+
const wrapper = document.querySelector('[role="menu"]')?.parentElement;
|
|
176
|
+
act(() => {
|
|
177
|
+
wrapper?.dispatchEvent(
|
|
178
|
+
new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }),
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
it("hover applies the look live after the debounce, without saving", () => {
|
|
184
|
+
const { setLive, setQuiet } = renderItems("picture", video);
|
|
185
|
+
openSubmenu("Look");
|
|
186
|
+
act(() => {
|
|
187
|
+
enter("Warm daylight");
|
|
188
|
+
vi.advanceTimersByTime(40);
|
|
189
|
+
});
|
|
190
|
+
expect(setLive).not.toHaveBeenCalled();
|
|
191
|
+
act(() => vi.advanceTimersByTime(60));
|
|
192
|
+
expect(setLive).toHaveBeenCalledWith(
|
|
193
|
+
video,
|
|
194
|
+
"data-color-grading",
|
|
195
|
+
'{"preset":"warm-daylight","intensity":1}',
|
|
196
|
+
);
|
|
197
|
+
expect(setQuiet).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("opening the submenu by click does not preview the auto-focused first option", () => {
|
|
201
|
+
const { setLive } = renderItems("picture", video);
|
|
202
|
+
openSubmenu("Look");
|
|
203
|
+
act(() => vi.advanceTimersByTime(200));
|
|
204
|
+
expect(setLive).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("keyboard focus previews and None previews no look", () => {
|
|
208
|
+
const { setLive } = renderItems("picture", video);
|
|
209
|
+
openSubmenu("Look");
|
|
210
|
+
act(() => {
|
|
211
|
+
option("None").dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
|
212
|
+
vi.advanceTimersByTime(100);
|
|
213
|
+
});
|
|
214
|
+
expect(setLive).toHaveBeenCalledWith(video, "data-color-grading", null);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("leaving the submenu reverts to the saved look", () => {
|
|
218
|
+
const { revertLive } = renderItems("picture", video);
|
|
219
|
+
openSubmenu("Look");
|
|
220
|
+
hover("Warm daylight");
|
|
221
|
+
leave();
|
|
222
|
+
expect(revertLive).toHaveBeenCalledWith(video, "data-color-grading");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("leaving before the debounce fires never previews or reverts", () => {
|
|
226
|
+
const { setLive, revertLive } = renderItems("picture", video);
|
|
227
|
+
openSubmenu("Look");
|
|
228
|
+
act(() => enter("Warm daylight"));
|
|
229
|
+
leave();
|
|
230
|
+
act(() => vi.advanceTimersByTime(200));
|
|
231
|
+
expect(setLive).not.toHaveBeenCalled();
|
|
232
|
+
expect(revertLive).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("clicking commits once and does not revert afterwards", () => {
|
|
236
|
+
const { setQuiet, revertLive } = renderItems("picture", video);
|
|
237
|
+
openSubmenu("Look");
|
|
238
|
+
hover("Warm daylight");
|
|
239
|
+
act(() => option("Warm daylight").click());
|
|
240
|
+
act(() => root?.unmount());
|
|
241
|
+
root = null;
|
|
242
|
+
expect(setQuiet).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(revertLive).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("closing the menu while previewing reverts", () => {
|
|
247
|
+
const { revertLive } = renderItems("picture", video);
|
|
248
|
+
openSubmenu("Look");
|
|
249
|
+
hover("Warm daylight");
|
|
250
|
+
act(() => root?.unmount());
|
|
251
|
+
root = null;
|
|
252
|
+
expect(revertLive).toHaveBeenCalledWith(video, "data-color-grading");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("does nothing when the host has no live callbacks", () => {
|
|
256
|
+
const { setQuiet } = renderItems("picture", video, 2, false);
|
|
257
|
+
openSubmenu("Look");
|
|
258
|
+
expect(() => {
|
|
259
|
+
hover("Warm daylight");
|
|
260
|
+
leave();
|
|
261
|
+
}).not.toThrow();
|
|
262
|
+
expect(setQuiet).not.toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
139
265
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useState, type KeyboardEvent } from "react";
|
|
1
|
+
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
|
2
2
|
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
3
3
|
import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
|
|
4
4
|
import type { TimelineElement } from "../store/playerStore";
|
|
@@ -30,6 +30,47 @@ const ROW_CLASS =
|
|
|
30
30
|
const DISABLED_ROW_CLASS =
|
|
31
31
|
"w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed";
|
|
32
32
|
const SUBMENU_WIDTH = 170;
|
|
33
|
+
const HOVER_PREVIEW_DELAY_MS = 80;
|
|
34
|
+
|
|
35
|
+
interface HoverPreview {
|
|
36
|
+
apply: (id: string | null) => void;
|
|
37
|
+
clear: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function useHoverPreview(preview: HoverPreview | undefined) {
|
|
41
|
+
const previewRef = useRef(preview);
|
|
42
|
+
previewRef.current = preview;
|
|
43
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
44
|
+
const activeRef = useRef(false);
|
|
45
|
+
|
|
46
|
+
const cancelTimer = () => {
|
|
47
|
+
if (timerRef.current === null) return;
|
|
48
|
+
clearTimeout(timerRef.current);
|
|
49
|
+
timerRef.current = null;
|
|
50
|
+
};
|
|
51
|
+
const end = () => {
|
|
52
|
+
cancelTimer();
|
|
53
|
+
if (!activeRef.current) return;
|
|
54
|
+
activeRef.current = false;
|
|
55
|
+
previewRef.current?.clear();
|
|
56
|
+
};
|
|
57
|
+
const settle = () => {
|
|
58
|
+
cancelTimer();
|
|
59
|
+
activeRef.current = false;
|
|
60
|
+
};
|
|
61
|
+
const start = (id: string | null) => {
|
|
62
|
+
if (!previewRef.current) return;
|
|
63
|
+
cancelTimer();
|
|
64
|
+
timerRef.current = setTimeout(() => {
|
|
65
|
+
timerRef.current = null;
|
|
66
|
+
activeRef.current = true;
|
|
67
|
+
previewRef.current?.apply(id);
|
|
68
|
+
}, HOVER_PREVIEW_DELAY_MS);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
useEffect(() => end, []);
|
|
72
|
+
return { start, end, settle };
|
|
73
|
+
}
|
|
33
74
|
|
|
34
75
|
interface ChoiceSection {
|
|
35
76
|
heading?: string;
|
|
@@ -48,22 +89,36 @@ function ChoiceSubmenu({
|
|
|
48
89
|
sections,
|
|
49
90
|
activeId,
|
|
50
91
|
onPick,
|
|
92
|
+
preview,
|
|
51
93
|
}: {
|
|
52
94
|
label: string;
|
|
53
95
|
sections: readonly ChoiceSection[];
|
|
54
96
|
activeId: string | null;
|
|
55
97
|
onPick: (id: string | null) => void;
|
|
98
|
+
preview?: HoverPreview;
|
|
56
99
|
}) {
|
|
57
100
|
const [open, setOpen] = useState(false);
|
|
58
101
|
const [flipLeft, setFlipLeft] = useState(false);
|
|
59
102
|
const rowRef = useRef<HTMLButtonElement | null>(null);
|
|
60
103
|
const submenuRef = useRef<HTMLDivElement | null>(null);
|
|
104
|
+
const hover = useHoverPreview(preview);
|
|
105
|
+
const openingFocusRef = useRef(false);
|
|
106
|
+
|
|
107
|
+
const hideSubmenu = () => {
|
|
108
|
+
hover.end();
|
|
109
|
+
setOpen(false);
|
|
110
|
+
};
|
|
61
111
|
|
|
62
112
|
const show = (focusFirst: boolean) => {
|
|
63
113
|
const rect = rowRef.current?.getBoundingClientRect();
|
|
64
114
|
setFlipLeft(rect ? rect.right + SUBMENU_WIDTH > window.innerWidth : false);
|
|
65
115
|
setOpen(true);
|
|
66
|
-
if (focusFirst)
|
|
116
|
+
if (!focusFirst) return;
|
|
117
|
+
openingFocusRef.current = true;
|
|
118
|
+
requestAnimationFrame(() => {
|
|
119
|
+
focusSibling(submenuRef.current, 1);
|
|
120
|
+
openingFocusRef.current = false;
|
|
121
|
+
});
|
|
67
122
|
};
|
|
68
123
|
|
|
69
124
|
const onSubmenuKeyDown = (event: KeyboardEvent) => {
|
|
@@ -74,7 +129,7 @@ function ChoiceSubmenu({
|
|
|
74
129
|
} else if (event.key === "ArrowLeft") {
|
|
75
130
|
event.preventDefault();
|
|
76
131
|
event.stopPropagation();
|
|
77
|
-
|
|
132
|
+
hideSubmenu();
|
|
78
133
|
rowRef.current?.focus();
|
|
79
134
|
}
|
|
80
135
|
};
|
|
@@ -86,7 +141,15 @@ function ChoiceSubmenu({
|
|
|
86
141
|
role="menuitemradio"
|
|
87
142
|
aria-checked={activeId === id}
|
|
88
143
|
className={ROW_CLASS}
|
|
89
|
-
onClick={() =>
|
|
144
|
+
onClick={() => {
|
|
145
|
+
hover.settle();
|
|
146
|
+
onPick(id);
|
|
147
|
+
}}
|
|
148
|
+
onMouseEnter={() => hover.start(id)}
|
|
149
|
+
onFocus={() => {
|
|
150
|
+
if (openingFocusRef.current) openingFocusRef.current = false;
|
|
151
|
+
else hover.start(id);
|
|
152
|
+
}}
|
|
90
153
|
>
|
|
91
154
|
<span className="flex items-center gap-1.5">
|
|
92
155
|
<span aria-hidden="true" className="w-3 text-center">
|
|
@@ -98,7 +161,7 @@ function ChoiceSubmenu({
|
|
|
98
161
|
);
|
|
99
162
|
|
|
100
163
|
return (
|
|
101
|
-
<div className="relative" onMouseEnter={() => show(false)} onMouseLeave={
|
|
164
|
+
<div className="relative" onMouseEnter={() => show(false)} onMouseLeave={hideSubmenu}>
|
|
102
165
|
<button
|
|
103
166
|
ref={rowRef}
|
|
104
167
|
type="button"
|
|
@@ -182,7 +245,8 @@ export function ClipMenuToolItems(props: ClipMenuToolItemsProps) {
|
|
|
182
245
|
}
|
|
183
246
|
|
|
184
247
|
function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsProps) {
|
|
185
|
-
const { onSetElementAttributeQuiet } =
|
|
248
|
+
const { onSetElementAttributeQuiet, onSetElementAttributeLive, onRevertElementAttributeLive } =
|
|
249
|
+
useTimelineEditContextOptional();
|
|
186
250
|
const state = useClipToolState(element);
|
|
187
251
|
const openCropBar = useCropPresetBarStore((s) => s.open);
|
|
188
252
|
if (!onSetElementAttributeQuiet) return null;
|
|
@@ -209,6 +273,14 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
|
|
|
209
273
|
}
|
|
210
274
|
|
|
211
275
|
if (!isPictureClip(state.tag)) return null;
|
|
276
|
+
const lookPreview =
|
|
277
|
+
onSetElementAttributeLive && onRevertElementAttributeLive
|
|
278
|
+
? {
|
|
279
|
+
apply: (id: string | null) =>
|
|
280
|
+
onSetElementAttributeLive(element, HF_COLOR_GRADING_ATTR, lookAttrValue(id)),
|
|
281
|
+
clear: () => onRevertElementAttributeLive(element, HF_COLOR_GRADING_ATTR),
|
|
282
|
+
}
|
|
283
|
+
: undefined;
|
|
212
284
|
return (
|
|
213
285
|
<>
|
|
214
286
|
<ChoiceSubmenu
|
|
@@ -216,6 +288,7 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
|
|
|
216
288
|
sections={LOOK_SECTIONS}
|
|
217
289
|
activeId={activeLook(state.colorGrading)}
|
|
218
290
|
onPick={(id) => write(HF_COLOR_GRADING_ATTR, lookAttrValue(id), "Look")}
|
|
291
|
+
preview={lookPreview}
|
|
219
292
|
/>
|
|
220
293
|
<button
|
|
221
294
|
type="button"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
|
+
import { resolvePreviewRelative } from "../../utils/previewRelativePath";
|
|
3
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
4
|
+
import { clipSourcePeak, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
|
|
5
|
+
import { encodePreviewPath, resolveMediaPreviewUrl } from "./thumbnailUtils";
|
|
6
|
+
|
|
7
|
+
const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
|
|
8
|
+
|
|
9
|
+
/** One fetch per peak-map URL per session; a failed or malformed answer reads as null. */
|
|
10
|
+
export function loadPeakMap(url: string): Promise<PeakMap | null> {
|
|
11
|
+
const pending = peakMapRequests.get(url);
|
|
12
|
+
if (pending) return pending;
|
|
13
|
+
const request = fetch(url)
|
|
14
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
15
|
+
.then((body: unknown) => (isPeakMap(body) ? body : null))
|
|
16
|
+
.catch(() => null);
|
|
17
|
+
peakMapRequests.set(url, request);
|
|
18
|
+
return request;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The studio-server `/peaks/*` URL for a clip's project media, or undefined for outside media. */
|
|
22
|
+
export function clipPeaksUrl(src: string | undefined, projectId: string): string | undefined {
|
|
23
|
+
const origin = window.location.origin;
|
|
24
|
+
const relative = resolvePreviewRelative(
|
|
25
|
+
resolveMediaPreviewUrl(src ?? "", projectId, origin),
|
|
26
|
+
projectId,
|
|
27
|
+
origin,
|
|
28
|
+
);
|
|
29
|
+
return relative
|
|
30
|
+
? buildProjectApiPath(projectId, `/peaks/${encodePreviewPath(relative)}`)
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clipSourceWindow(el: TimelineElement): ClipSourceWindow {
|
|
35
|
+
return { mediaStart: el.playbackStart ?? 0, sourceSpan: el.duration * (el.playbackRate ?? 1) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The clip's source peak, linear, over the part it plays; null when it cannot be measured. */
|
|
39
|
+
export async function measureClipSourcePeak(
|
|
40
|
+
el: TimelineElement,
|
|
41
|
+
projectId: string,
|
|
42
|
+
): Promise<number | null> {
|
|
43
|
+
const url = clipPeaksUrl(el.src, projectId);
|
|
44
|
+
const map = url ? await loadPeakMap(url) : null;
|
|
45
|
+
return map ? clipSourcePeak(map, clipSourceWindow(el)) : null;
|
|
46
|
+
}
|