@hyperframes/studio 0.8.108 → 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-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
- package/dist/assets/{index-C5dyUHwk.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-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +44 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7021 -5669
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/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/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +8 -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 +3 -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/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +36 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/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 +6 -0
- 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 +52 -9
- package/src/player/components/clipMenuAudioItems.tsx +31 -23
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuToolItems.tsx +1 -3
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +11 -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/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/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { resolveLinkMenuItems } from "./clipMenuLinkItems";
|
|
4
|
+
|
|
5
|
+
const clip = (id: string, tag: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
|
|
6
|
+
id,
|
|
7
|
+
tag,
|
|
8
|
+
src: "talk.mp4",
|
|
9
|
+
start: 0,
|
|
10
|
+
duration: 4,
|
|
11
|
+
track: 0,
|
|
12
|
+
...extra,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function labels(element: TimelineElement, elements: TimelineElement[], selected: string[] = []) {
|
|
16
|
+
return resolveLinkMenuItems({
|
|
17
|
+
element,
|
|
18
|
+
elements,
|
|
19
|
+
selectedKeys: new Set(selected),
|
|
20
|
+
onLinkEdit: vi.fn(),
|
|
21
|
+
onDeleteElementOnly: vi.fn(),
|
|
22
|
+
}).map((item) => item.label);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("resolveLinkMenuItems", () => {
|
|
26
|
+
it("offers Detach audio on a video with sound (frame 4)", () => {
|
|
27
|
+
const talk = clip("talk", "video", { hasAudio: true });
|
|
28
|
+
expect(labels(talk, [talk])).toEqual(["Detach audio"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("offers Unlink, Merge back and Delete this clip only on a linked pair (frame 6)", () => {
|
|
32
|
+
const video = clip("talk", "video", { muted: true, link: "lk-1" });
|
|
33
|
+
const audio = clip("talk-audio", "audio", { link: "lk-1" });
|
|
34
|
+
expect(labels(audio, [video, audio])).toEqual([
|
|
35
|
+
"Unlink from video",
|
|
36
|
+
"Merge audio back into video",
|
|
37
|
+
"Delete this clip only",
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("offers Link instead of Unlink for a selected unlinked pair", () => {
|
|
42
|
+
const video = clip("talk", "video", { muted: true });
|
|
43
|
+
const audio = clip("talk-audio", "audio");
|
|
44
|
+
expect(labels(video, [video, audio], ["talk", "talk-audio"])).toEqual([
|
|
45
|
+
"Link to audio",
|
|
46
|
+
"Merge audio back into video",
|
|
47
|
+
]);
|
|
48
|
+
expect(labels(audio, [video, audio], ["talk", "talk-audio"])[0]).toBe("Link to video");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("names the audio partner when unlinking from the video side", () => {
|
|
52
|
+
const video = clip("talk", "video", { muted: true, link: "lk-1" });
|
|
53
|
+
const audio = clip("talk-audio", "audio", { link: "lk-1" });
|
|
54
|
+
expect(labels(video, [video, audio])[0]).toBe("Unlink from audio");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("falls back to plain Unlink for groups of three or same-kind partners", () => {
|
|
58
|
+
const video = clip("talk", "video", { muted: true, link: "lk-1" });
|
|
59
|
+
const audio = clip("talk-audio", "audio", { link: "lk-1" });
|
|
60
|
+
const second = clip("talk-audio2", "audio", { link: "lk-1" });
|
|
61
|
+
expect(labels(video, [video, audio, second])[0]).toBe("Unlink");
|
|
62
|
+
const twin = clip("talk2", "video", { muted: true, link: "lk-2" });
|
|
63
|
+
const twin2 = clip("talk3", "video", { muted: true, link: "lk-2" });
|
|
64
|
+
expect(labels(twin, [twin, twin2])[0]).toBe("Unlink");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("offers nothing for a silent video or without a link handler", () => {
|
|
68
|
+
const broll = clip("broll", "video", { muted: true, src: "b.mp4" });
|
|
69
|
+
expect(labels(broll, [broll])).toEqual([]);
|
|
70
|
+
expect(
|
|
71
|
+
resolveLinkMenuItems({
|
|
72
|
+
element: clip("t", "video", { hasAudio: true }),
|
|
73
|
+
elements: [],
|
|
74
|
+
selectedKeys: new Set(),
|
|
75
|
+
}),
|
|
76
|
+
).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("each item dispatches its link edit", () => {
|
|
80
|
+
const onLinkEdit = vi.fn();
|
|
81
|
+
const talk = clip("talk", "video", { hasAudio: true });
|
|
82
|
+
resolveLinkMenuItems({
|
|
83
|
+
element: talk,
|
|
84
|
+
elements: [talk],
|
|
85
|
+
selectedKeys: new Set(),
|
|
86
|
+
onLinkEdit,
|
|
87
|
+
})[0]?.run();
|
|
88
|
+
expect(onLinkEdit).toHaveBeenCalledWith({ kind: "detach", element: talk });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
3
|
+
import type { TimelineLinkEdit } from "./timelineCallbacks";
|
|
4
|
+
import { linkedMembersOf } from "./audioClipLink";
|
|
5
|
+
import { canDetachAudio, canLinkPair, findMergePair } from "../../components/editor/mediaLinkEdits";
|
|
6
|
+
|
|
7
|
+
interface LinkMenuItem {
|
|
8
|
+
label: string;
|
|
9
|
+
shortcut?: string;
|
|
10
|
+
destructive?: boolean;
|
|
11
|
+
run: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
15
|
+
|
|
16
|
+
const tagOf = (el: TimelineElement) => el.tag.trim().toLowerCase();
|
|
17
|
+
|
|
18
|
+
function partnerSuffix(element: TimelineElement, others: readonly TimelineElement[]): string {
|
|
19
|
+
const partners = others.filter((el) => keyOf(el) !== keyOf(element));
|
|
20
|
+
const [partner] = partners;
|
|
21
|
+
if (partners.length !== 1 || !partner) return "";
|
|
22
|
+
const pair = [tagOf(element), tagOf(partner)].sort().join("+");
|
|
23
|
+
return pair === "audio+video" ? ` ${tagOf(partner)}` : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The link-model items for a clip, in wireframe order (detach · unlink/link · merge · delete-one). */
|
|
27
|
+
export function resolveLinkMenuItems(input: {
|
|
28
|
+
element: TimelineElement;
|
|
29
|
+
elements: readonly TimelineElement[];
|
|
30
|
+
selectedKeys: ReadonlySet<string>;
|
|
31
|
+
onLinkEdit?: (edit: TimelineLinkEdit) => unknown;
|
|
32
|
+
onDeleteElementOnly?: (element: TimelineElement) => unknown;
|
|
33
|
+
}): LinkMenuItem[] {
|
|
34
|
+
const { element, elements, selectedKeys, onLinkEdit, onDeleteElementOnly } = input;
|
|
35
|
+
const items: LinkMenuItem[] = [];
|
|
36
|
+
if (!onLinkEdit) return items;
|
|
37
|
+
const members = linkedMembersOf(element, elements);
|
|
38
|
+
const linked = members.length > 1;
|
|
39
|
+
if (canDetachAudio(element)) {
|
|
40
|
+
items.push({
|
|
41
|
+
label: "Detach audio",
|
|
42
|
+
shortcut: "⌥⇧D",
|
|
43
|
+
run: () => onLinkEdit({ kind: "detach", element }),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const selected = elements.filter((el) => selectedKeys.has(keyOf(el)));
|
|
47
|
+
if (linked) {
|
|
48
|
+
items.push({
|
|
49
|
+
label: `Unlink${partnerSuffix(element, members) && ` from${partnerSuffix(element, members)}`}`,
|
|
50
|
+
shortcut: "⌘L",
|
|
51
|
+
run: () => onLinkEdit({ kind: "unlink", elements: members }),
|
|
52
|
+
});
|
|
53
|
+
} else if (canLinkPair(selected) && selectedKeys.has(keyOf(element))) {
|
|
54
|
+
items.push({
|
|
55
|
+
label: `Link${partnerSuffix(element, selected) && ` to${partnerSuffix(element, selected)}`}`,
|
|
56
|
+
shortcut: "⌘L",
|
|
57
|
+
run: () => onLinkEdit({ kind: "link", elements: selected }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const pair = findMergePair(element, elements);
|
|
61
|
+
if (pair) {
|
|
62
|
+
items.push({
|
|
63
|
+
label: "Merge audio back into video",
|
|
64
|
+
run: () => onLinkEdit({ kind: "merge", ...pair }),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (linked && onDeleteElementOnly) {
|
|
68
|
+
items.push({
|
|
69
|
+
label: "Delete this clip only",
|
|
70
|
+
shortcut: "⌥⌫",
|
|
71
|
+
destructive: true,
|
|
72
|
+
run: () => onDeleteElementOnly(element),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return items;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function ClipMenuLinkItems({
|
|
79
|
+
part,
|
|
80
|
+
element,
|
|
81
|
+
onClose,
|
|
82
|
+
}: {
|
|
83
|
+
part: "link" | "delete";
|
|
84
|
+
element: TimelineElement;
|
|
85
|
+
onClose: () => void;
|
|
86
|
+
}) {
|
|
87
|
+
const { onLinkEdit, onDeleteElementOnly } = useTimelineEditContextOptional();
|
|
88
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
89
|
+
const selectedKeys = usePlayerStore((s) => s.selectedElementIds);
|
|
90
|
+
const items = resolveLinkMenuItems({
|
|
91
|
+
element,
|
|
92
|
+
elements,
|
|
93
|
+
selectedKeys,
|
|
94
|
+
onLinkEdit,
|
|
95
|
+
onDeleteElementOnly,
|
|
96
|
+
}).filter((item) => (item.destructive === true) === (part === "delete"));
|
|
97
|
+
if (items.length === 0) return null;
|
|
98
|
+
return (
|
|
99
|
+
<>
|
|
100
|
+
{items.map((item) => (
|
|
101
|
+
<button
|
|
102
|
+
key={item.label}
|
|
103
|
+
type="button"
|
|
104
|
+
role="menuitem"
|
|
105
|
+
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden cursor-pointer hover:bg-neutral-800 focus-visible:bg-neutral-800 ${
|
|
106
|
+
item.destructive ? "text-red-400" : "text-neutral-300"
|
|
107
|
+
}`}
|
|
108
|
+
onClick={() => {
|
|
109
|
+
item.run();
|
|
110
|
+
onClose();
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<span>{item.label}</span>
|
|
114
|
+
{item.shortcut && (
|
|
115
|
+
<span className="text-neutral-500 text-[10px] ml-3">{item.shortcut}</span>
|
|
116
|
+
)}
|
|
117
|
+
</button>
|
|
118
|
+
))}
|
|
119
|
+
</>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -175,7 +175,7 @@ function FreezeFrameItem({ element, currentTime, onClose }: Omit<ClipMenuToolIte
|
|
|
175
175
|
);
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
/** Freeze (time), Voice (sound), Look and Crop (picture)
|
|
178
|
+
/** Freeze (time), Voice (sound), Look and Crop (picture). */
|
|
179
179
|
export function ClipMenuToolItems(props: ClipMenuToolItemsProps) {
|
|
180
180
|
if (props.group === "time") return <FreezeFrameItem {...props} />;
|
|
181
181
|
return <ClipMenuAttributeItems {...props} />;
|
|
@@ -204,7 +204,6 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
|
|
|
204
204
|
write(HF_AUDIO_FX_ATTR, chainWithVoicePreset(state.fxChain, id), "Voice preset")
|
|
205
205
|
}
|
|
206
206
|
/>
|
|
207
|
-
<div className="my-1 border-t border-neutral-700/60" />
|
|
208
207
|
</>
|
|
209
208
|
);
|
|
210
209
|
}
|
|
@@ -229,7 +228,6 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
|
|
|
229
228
|
>
|
|
230
229
|
<span>Crop</span>
|
|
231
230
|
</button>
|
|
232
|
-
<div className="my-1 border-t border-neutral-700/60" />
|
|
233
231
|
</>
|
|
234
232
|
);
|
|
235
233
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clipSyncState, syncPartnerOf } from "./clipSync";
|
|
3
|
+
|
|
4
|
+
const clip = (id: string, tag: string, start: number, extra: Record<string, unknown> = {}) => ({
|
|
5
|
+
id,
|
|
6
|
+
tag,
|
|
7
|
+
start,
|
|
8
|
+
duration: 4,
|
|
9
|
+
track: 0,
|
|
10
|
+
syncOrigin: "lk-1",
|
|
11
|
+
...extra,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("syncPartnerOf", () => {
|
|
15
|
+
it("pairs a video with the audio sharing its sync origin, not another video", () => {
|
|
16
|
+
const v = clip("v", "video", 0);
|
|
17
|
+
const a = clip("a", "audio", 0);
|
|
18
|
+
const other = clip("v2", "video", 0);
|
|
19
|
+
expect(syncPartnerOf(a, [v, a, other])?.id).toBe("v");
|
|
20
|
+
expect(syncPartnerOf(v, [v, a, other])?.id).toBe("a");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("ignores clips with no or another origin", () => {
|
|
24
|
+
const v = clip("v", "video", 0);
|
|
25
|
+
expect(syncPartnerOf(v, [v, clip("a", "audio", 0, { syncOrigin: "lk-2" })])).toBeNull();
|
|
26
|
+
expect(syncPartnerOf({ ...v, syncOrigin: undefined }, [v])).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it.each([{ sourceFile: "child.html" }, { compositionScope: "child" }])(
|
|
30
|
+
"never pairs across compositions reusing an origin (%o)",
|
|
31
|
+
(scope) => {
|
|
32
|
+
const v = clip("v", "video", 0);
|
|
33
|
+
expect(syncPartnerOf(v, [v, clip("a", "audio", 0, scope)])).toBeNull();
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
it("after a split, picks the partner sharing the most timeline", () => {
|
|
38
|
+
const a = clip("a", "audio", 4.2);
|
|
39
|
+
const left = clip("v", "video", 0);
|
|
40
|
+
const right = clip("v-split", "video", 4);
|
|
41
|
+
expect(syncPartnerOf(a, [left, right, a])?.id).toBe("v-split");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("clipSyncState", () => {
|
|
46
|
+
it("is null in sync and signed per side when drifted", () => {
|
|
47
|
+
const v = clip("v", "video", 1);
|
|
48
|
+
expect(clipSyncState(v, [v, clip("a", "audio", 1)], 30)).toBeNull();
|
|
49
|
+
const late = clip("a", "audio", 1 + 10 / 30);
|
|
50
|
+
expect(clipSyncState(late, [v, late], 30)?.frames).toBe(10);
|
|
51
|
+
expect(clipSyncState(v, [v, late], 30)?.frames).toBe(-10);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("offers move and slip targets that each restore sync", () => {
|
|
55
|
+
const v = clip("v", "video", 1, { playbackStart: 0 });
|
|
56
|
+
const a = clip("a", "audio", 1.5, { playbackStart: 0 });
|
|
57
|
+
const state = clipSyncState(a, [v, a], 30);
|
|
58
|
+
expect(state?.moveStart).toBeCloseTo(1);
|
|
59
|
+
expect(state?.slipMediaStart).toBeCloseTo(0.5);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("has no badge when the halves play at different rates", () => {
|
|
63
|
+
const v = clip("v", "video", 1, { playbackRate: 2 });
|
|
64
|
+
const a = clip("a", "audio", 2);
|
|
65
|
+
expect(clipSyncState(a, [v, a], 30)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
moveIntoSyncStart,
|
|
3
|
+
slipIntoSyncMediaStart,
|
|
4
|
+
syncOffsetFrames,
|
|
5
|
+
type SyncTiming,
|
|
6
|
+
} from "@hyperframes/core/media-link";
|
|
7
|
+
import { sameCompositionScope, type TimelineElement } from "../store/timelineElement";
|
|
8
|
+
|
|
9
|
+
type SyncElement = Pick<
|
|
10
|
+
TimelineElement,
|
|
11
|
+
"id" | "key" | "tag" | "start" | "duration" | "playbackStart" | "playbackRate" | "syncOrigin"
|
|
12
|
+
> &
|
|
13
|
+
Pick<TimelineElement, "sourceFile" | "compositionScope">;
|
|
14
|
+
|
|
15
|
+
const keyOf = (el: Pick<TimelineElement, "id" | "key">) => el.key ?? el.id;
|
|
16
|
+
const kindOf = (el: Pick<TimelineElement, "tag">) => el.tag.trim().toLowerCase();
|
|
17
|
+
|
|
18
|
+
function syncTimingOf(el: SyncElement): SyncTiming {
|
|
19
|
+
return { start: el.start, mediaStart: el.playbackStart ?? 0, playbackRate: el.playbackRate ?? 1 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function overlap(a: SyncElement, b: SyncElement): number {
|
|
23
|
+
return Math.min(a.start + a.duration, b.start + b.duration) - Math.max(a.start, b.start);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The other half of `el`'s source pair: a video for an audio and the reverse,
|
|
28
|
+
* sharing its sync origin. After splits several may qualify; the one sharing the
|
|
29
|
+
* most timeline wins, then the nearest start.
|
|
30
|
+
*/
|
|
31
|
+
export function syncPartnerOf<T extends SyncElement>(el: T, elements: readonly T[]): T | null {
|
|
32
|
+
const kind = kindOf(el);
|
|
33
|
+
if (!el.syncOrigin || (kind !== "video" && kind !== "audio")) return null;
|
|
34
|
+
const partnerKind = kind === "video" ? "audio" : "video";
|
|
35
|
+
const candidates = elements.filter(
|
|
36
|
+
(other) =>
|
|
37
|
+
keyOf(other) !== keyOf(el) &&
|
|
38
|
+
other.syncOrigin === el.syncOrigin &&
|
|
39
|
+
sameCompositionScope(other, el) &&
|
|
40
|
+
kindOf(other) === partnerKind,
|
|
41
|
+
);
|
|
42
|
+
const rank = (other: T) => [overlap(el, other), -Math.abs(other.start - el.start)] as const;
|
|
43
|
+
let best: T | null = null;
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
if (!best) best = candidate;
|
|
46
|
+
else {
|
|
47
|
+
const [o1, d1] = rank(candidate);
|
|
48
|
+
const [o2, d2] = rank(best);
|
|
49
|
+
if (o1 > o2 || (o1 === o2 && d1 > d2)) best = candidate;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return best;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ClipSyncState<T> {
|
|
56
|
+
partner: T;
|
|
57
|
+
frames: number;
|
|
58
|
+
moveStart: number | null;
|
|
59
|
+
slipMediaStart: number | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `el`'s drift from its source partner, or null when in sync, unpaired or at a different rate. */
|
|
63
|
+
export function clipSyncState<T extends SyncElement>(
|
|
64
|
+
el: T,
|
|
65
|
+
elements: readonly T[],
|
|
66
|
+
fps: number,
|
|
67
|
+
): ClipSyncState<T> | null {
|
|
68
|
+
const partner = syncPartnerOf(el, elements);
|
|
69
|
+
if (!partner) return null;
|
|
70
|
+
const self = syncTimingOf(el);
|
|
71
|
+
const other = syncTimingOf(partner);
|
|
72
|
+
const frames = syncOffsetFrames(self, other, fps);
|
|
73
|
+
if (frames === null || frames === 0) return null;
|
|
74
|
+
return {
|
|
75
|
+
partner,
|
|
76
|
+
frames,
|
|
77
|
+
moveStart: moveIntoSyncStart(self, other),
|
|
78
|
+
slipMediaStart: slipIntoSyncMediaStart(self, other),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { LINK_LABEL_COLORS, linkLabelColor } from "./linkLabelColor";
|
|
3
|
+
|
|
4
|
+
describe("linkLabelColor", () => {
|
|
5
|
+
it("has no colour for an unlinked clip", () => {
|
|
6
|
+
expect(linkLabelColor(undefined)).toBeNull();
|
|
7
|
+
expect(linkLabelColor("")).toBeNull();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("walks the palette in lk-N order, wrapping", () => {
|
|
11
|
+
expect(linkLabelColor("lk-1")).toBe(LINK_LABEL_COLORS[0]);
|
|
12
|
+
expect(linkLabelColor("lk-2")).toBe(LINK_LABEL_COLORS[1]);
|
|
13
|
+
expect(linkLabelColor(`lk-${LINK_LABEL_COLORS.length + 1}`)).toBe(LINK_LABEL_COLORS[0]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("is deterministic for hand-written ids", () => {
|
|
17
|
+
expect(linkLabelColor("talk-pair")).toBe(linkLabelColor("talk-pair"));
|
|
18
|
+
expect(LINK_LABEL_COLORS).toContain(linkLabelColor("talk-pair"));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Link-group label colours; teal, violet, white and red belong to clips, selection and the sync badge. */
|
|
2
|
+
export const LINK_LABEL_COLORS = [
|
|
3
|
+
"var(--color-amber-500)",
|
|
4
|
+
"var(--color-sky-400)",
|
|
5
|
+
"var(--color-green-400)",
|
|
6
|
+
"var(--color-pink-400)",
|
|
7
|
+
"var(--color-orange-400)",
|
|
8
|
+
"var(--color-amber-300)",
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
function stableIndex(link: string): number {
|
|
12
|
+
const numbered = /^lk-(\d+)$/.exec(link);
|
|
13
|
+
if (numbered) return Number(numbered[1]) - 1;
|
|
14
|
+
let hash = 0;
|
|
15
|
+
for (const char of link) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
|
16
|
+
return hash;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The same colour for every member of a link group, from its id alone. */
|
|
20
|
+
export function linkLabelColor(link: string | undefined): string | null {
|
|
21
|
+
if (!link) return null;
|
|
22
|
+
const count = LINK_LABEL_COLORS.length;
|
|
23
|
+
return LINK_LABEL_COLORS[((stableIndex(link) % count) + count) % count] ?? null;
|
|
24
|
+
}
|
|
@@ -39,6 +39,14 @@ export interface TimelineDropCallbacks {
|
|
|
39
39
|
) => Promise<void> | void;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export type TimelineLinkEdit =
|
|
43
|
+
| { kind: "unlink"; elements: readonly TimelineElement[] }
|
|
44
|
+
| { kind: "link"; elements: readonly TimelineElement[] }
|
|
45
|
+
| { kind: "detach"; element: TimelineElement }
|
|
46
|
+
| { kind: "merge"; video: TimelineElement; audio: TimelineElement }
|
|
47
|
+
| { kind: "move-into-sync"; element: TimelineElement; start: number }
|
|
48
|
+
| { kind: "slip-into-sync"; element: TimelineElement; mediaStart: number };
|
|
49
|
+
|
|
42
50
|
export interface TimelineEditCallbacks {
|
|
43
51
|
onMoveElement?: (
|
|
44
52
|
element: TimelineElement,
|
|
@@ -115,10 +123,13 @@ export interface TimelineEditCallbacks {
|
|
|
115
123
|
label: string,
|
|
116
124
|
) => Promise<TimelineEditOutcome | void>;
|
|
117
125
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
126
|
+
onLinkEdit?: (edit: TimelineLinkEdit) => Promise<void> | void;
|
|
127
|
+
onDeleteElementOnly?: (element: TimelineElement) => Promise<void> | void;
|
|
118
128
|
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
119
129
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
120
130
|
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
|
121
131
|
onFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
|
|
132
|
+
onNotice?: (message: string, tone?: "error" | "info") => void;
|
|
122
133
|
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
123
134
|
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
|
|
124
135
|
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
@@ -70,6 +70,7 @@ interface TimelineClipDragGestureLifecycleInput {
|
|
|
70
70
|
onBlockedEditAttemptRef: RefObject<
|
|
71
71
|
((element: TimelineElement, intent: BlockedClipState["intent"]) => void) | undefined
|
|
72
72
|
>;
|
|
73
|
+
onLinkEditRef: RefObject<TimelineEditCallbacks["onLinkEdit"]>;
|
|
73
74
|
readZIndexRef: RefObject<((element: TimelineElement) => number) | undefined>;
|
|
74
75
|
onStackingPatchesRef: RefObject<
|
|
75
76
|
((patches: StackingPatch[]) => Promise<unknown> | void) | undefined
|
|
@@ -107,6 +108,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
107
108
|
onResizeElementRef,
|
|
108
109
|
onResizeElementsRef,
|
|
109
110
|
onBlockedEditAttemptRef,
|
|
111
|
+
onLinkEditRef,
|
|
110
112
|
readZIndexRef,
|
|
111
113
|
onStackingPatchesRef,
|
|
112
114
|
refreshAfterLaneMoveRef,
|
|
@@ -228,6 +230,11 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
228
230
|
if (drag && pointerMatchesGesture(event)) handleDragPointerMove(event, drag);
|
|
229
231
|
};
|
|
230
232
|
|
|
233
|
+
const unlinkAltEdited = (gesture: { element: TimelineElement; altKey?: boolean }) => {
|
|
234
|
+
if (!gesture.altKey || !gesture.element.link) return;
|
|
235
|
+
void onLinkEditRef.current?.({ kind: "unlink", elements: [gesture.element] });
|
|
236
|
+
};
|
|
237
|
+
|
|
231
238
|
const commitResizePointerUp = (
|
|
232
239
|
resize: ResizingClipState,
|
|
233
240
|
groupSession: TimelineGroupResizeSession | null,
|
|
@@ -271,6 +278,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
271
278
|
]);
|
|
272
279
|
console.error("[Timeline] Failed to persist clip resize", error);
|
|
273
280
|
});
|
|
281
|
+
unlinkAltEdited(resize);
|
|
274
282
|
};
|
|
275
283
|
|
|
276
284
|
const finishBlockedPointerUp = (blocked: BlockedClipState) => {
|
|
@@ -293,6 +301,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
293
301
|
onStackingPatches: onStackingPatchesRef.current,
|
|
294
302
|
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
|
295
303
|
});
|
|
304
|
+
unlinkAltEdited(drag);
|
|
296
305
|
};
|
|
297
306
|
|
|
298
307
|
/** Group gestures commit atomically: one missing member cancels the whole resize. */
|
|
@@ -33,6 +33,7 @@ export interface DraggedClipState {
|
|
|
33
33
|
snapTime: number | null;
|
|
34
34
|
snapType: TimelineSnapType | null;
|
|
35
35
|
started: boolean;
|
|
36
|
+
altKey?: boolean;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
export interface ResizingClipState {
|
|
@@ -63,6 +64,7 @@ export interface ResizingClipState {
|
|
|
63
64
|
playbackStart?: number;
|
|
64
65
|
}[];
|
|
65
66
|
started: boolean;
|
|
67
|
+
altKey?: boolean;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export interface BlockedClipState {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
9
9
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
10
10
|
import { CLIP_HANDLE_W } from "./timelineLayout";
|
|
11
|
+
import { selectClipWithLinks } from "./timelineLinkSelection";
|
|
11
12
|
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
12
13
|
|
|
13
14
|
export interface ClipGestureDeps {
|
|
@@ -145,6 +146,7 @@ export function createClipGestureHandlers(
|
|
|
145
146
|
previewDuration: el.duration,
|
|
146
147
|
previewPlaybackStart: el.playbackStart,
|
|
147
148
|
started: false,
|
|
149
|
+
altKey: e.altKey,
|
|
148
150
|
});
|
|
149
151
|
};
|
|
150
152
|
|
|
@@ -191,6 +193,7 @@ export function createClipGestureHandlers(
|
|
|
191
193
|
snapTime: null,
|
|
192
194
|
snapType: null,
|
|
193
195
|
started: false,
|
|
196
|
+
altKey: e.altKey,
|
|
194
197
|
});
|
|
195
198
|
};
|
|
196
199
|
|
|
@@ -217,7 +220,7 @@ export function createClipGestureHandlers(
|
|
|
217
220
|
return;
|
|
218
221
|
}
|
|
219
222
|
// Clip selection is idempotent; empty timeline space owns deselection.
|
|
220
|
-
|
|
223
|
+
selectClipWithLinks(elementKey, e.altKey, setSelectedElementId);
|
|
221
224
|
onSelectElement?.(el);
|
|
222
225
|
};
|
|
223
226
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
2
|
+
import { expandToLinkedMembers } from "./audioClipLink";
|
|
3
|
+
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Select `key` the way a clip click does, then widen to link partners; Alt
|
|
7
|
+
* (or Linked Selection off) selects `key` alone, even inside a larger selection.
|
|
8
|
+
*/
|
|
9
|
+
export function selectClipWithLinks(
|
|
10
|
+
key: string,
|
|
11
|
+
altKey: boolean,
|
|
12
|
+
setSelectedElementId: (id: string) => void,
|
|
13
|
+
): void {
|
|
14
|
+
const state = usePlayerStore.getState();
|
|
15
|
+
if (altKey || !isLinkedSelectionOn()) {
|
|
16
|
+
state.setSelection([key], key);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
setSelectedElementId(key);
|
|
20
|
+
const { selectedElementIds, elements } = usePlayerStore.getState();
|
|
21
|
+
const expanded = expandToLinkedMembers(selectedElementIds, elements);
|
|
22
|
+
if (expanded.size > selectedElementIds.size) state.setSelection(expanded, key);
|
|
23
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import type { ClipContextMenuState } from "./TimelineProvider";
|
|
4
|
+
import { selectClipWithLinks } from "./timelineLinkSelection";
|
|
4
5
|
|
|
5
6
|
/** A clicked member of an active multi-selection keeps the whole group
|
|
6
7
|
* selected, so the context menu it opens acts on all of it. */
|
|
@@ -16,7 +17,7 @@ export function useClipContextMenu(
|
|
|
16
17
|
e.preventDefault();
|
|
17
18
|
const id = el.key ?? el.id;
|
|
18
19
|
if (!(selectedElementIds.size > 1 && selectedElementIds.has(id))) {
|
|
19
|
-
|
|
20
|
+
selectClipWithLinks(id, e.altKey, setSelectedElementId);
|
|
20
21
|
onSelectElement?.(el);
|
|
21
22
|
}
|
|
22
23
|
dismissGapMenu();
|
|
@@ -27,6 +27,8 @@ import type {
|
|
|
27
27
|
BlockedClipState,
|
|
28
28
|
} from "./timelineClipDragTypes";
|
|
29
29
|
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
30
|
+
import { linkedGestureKeys } from "./audioClipLink";
|
|
31
|
+
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
|
|
30
32
|
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
31
33
|
import {
|
|
32
34
|
mountTimelineClipDragGestureLifecycle,
|
|
@@ -65,6 +67,7 @@ interface UseTimelineClipDragInput {
|
|
|
65
67
|
) => Promise<void> | void;
|
|
66
68
|
onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
|
|
67
69
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
|
|
70
|
+
onLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
|
|
68
71
|
/** Seeks the preview; a trim shows the frame at its dragged edge. */
|
|
69
72
|
onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
|
|
70
73
|
setShowPopover: (show: boolean) => void;
|
|
@@ -95,6 +98,7 @@ export function useTimelineClipDrag({
|
|
|
95
98
|
onResizeElement,
|
|
96
99
|
onResizeElements,
|
|
97
100
|
onBlockedEditAttempt,
|
|
101
|
+
onLinkEdit,
|
|
98
102
|
onSeek,
|
|
99
103
|
setShowPopover,
|
|
100
104
|
setRangeSelectionRef,
|
|
@@ -220,6 +224,13 @@ export function useTimelineClipDrag({
|
|
|
220
224
|
return;
|
|
221
225
|
}
|
|
222
226
|
beginGesture("drag", next.pointerId);
|
|
227
|
+
gestureSelectedKeysRef.current = linkedGestureKeys(
|
|
228
|
+
gestureSelectedKeysRef.current,
|
|
229
|
+
next.element,
|
|
230
|
+
elementsRef.current,
|
|
231
|
+
next.altKey === true,
|
|
232
|
+
isLinkedSelectionOn(),
|
|
233
|
+
);
|
|
223
234
|
publishDraggedClip(next);
|
|
224
235
|
},
|
|
225
236
|
[beginGesture, publishDraggedClip],
|
|
@@ -231,6 +242,13 @@ export function useTimelineClipDrag({
|
|
|
231
242
|
return;
|
|
232
243
|
}
|
|
233
244
|
beginGesture("resize", next.pointerId);
|
|
245
|
+
gestureSelectedKeysRef.current = linkedGestureKeys(
|
|
246
|
+
gestureSelectedKeysRef.current,
|
|
247
|
+
next.element,
|
|
248
|
+
elementsRef.current,
|
|
249
|
+
next.altKey === true,
|
|
250
|
+
isLinkedSelectionOn(),
|
|
251
|
+
);
|
|
234
252
|
publishResizingClip(next);
|
|
235
253
|
},
|
|
236
254
|
[beginGesture, publishResizingClip],
|
|
@@ -249,6 +267,8 @@ export function useTimelineClipDrag({
|
|
|
249
267
|
onMoveElementsRef.current = onMoveElements;
|
|
250
268
|
const onBlockedEditAttemptRef = useRef(onBlockedEditAttempt);
|
|
251
269
|
onBlockedEditAttemptRef.current = onBlockedEditAttempt;
|
|
270
|
+
const onLinkEditRef = useRef(onLinkEdit);
|
|
271
|
+
onLinkEditRef.current = onLinkEdit;
|
|
252
272
|
const onResizeElementRef = useRef(onResizeElement);
|
|
253
273
|
onResizeElementRef.current = onResizeElement;
|
|
254
274
|
const onResizeElementsRef = useRef(onResizeElements);
|
|
@@ -422,6 +442,7 @@ export function useTimelineClipDrag({
|
|
|
422
442
|
refreshAfterLaneMoveRef,
|
|
423
443
|
readZIndexRef,
|
|
424
444
|
onBlockedEditAttemptRef,
|
|
445
|
+
onLinkEditRef,
|
|
425
446
|
onResizeElementsRef,
|
|
426
447
|
onResizeElementRef,
|
|
427
448
|
onMoveElementsRef,
|