@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.
Files changed (118) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
  5. package/dist/assets/{index-C5dyUHwk.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +44 -4
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +7021 -5669
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  24. package/src/components/editor/InlineTextCaret.tsx +211 -0
  25. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  26. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  27. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  28. package/src/components/editor/domEditInlineText.ts +6 -4
  29. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  30. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  31. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  32. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  33. package/src/components/editor/mediaAudioEdits.ts +27 -14
  34. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  35. package/src/components/editor/mediaLinkEdits.ts +282 -0
  36. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  37. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  38. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  39. package/src/components/editor/useVolumeAutomation.ts +29 -7
  40. package/src/components/nle/useTimelineEditCallbacks.ts +8 -0
  41. package/src/components/ui/Menu.test.tsx +3 -22
  42. package/src/components/ui/Menu.tsx +2 -2
  43. package/src/components/ui/Popover.tsx +2 -2
  44. package/src/components/ui/Select.tsx +2 -2
  45. package/src/components/ui/Tooltip.tsx +2 -2
  46. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  47. package/src/components/ui/valueControls.test.tsx +2 -21
  48. package/src/contexts/TimelineEditContext.tsx +3 -0
  49. package/src/hooks/appHotkeysDispatch.ts +2 -1
  50. package/src/hooks/linkShortcuts.test.ts +70 -0
  51. package/src/hooks/linkShortcuts.ts +41 -0
  52. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  53. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  54. package/src/hooks/timelineMoveAdapter.ts +13 -0
  55. package/src/hooks/timelineTimingSync.test.ts +59 -0
  56. package/src/hooks/timelineTimingSync.ts +22 -3
  57. package/src/hooks/useAppHotkeys.ts +9 -0
  58. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  59. package/src/hooks/useGsapScriptCommits.ts +23 -3
  60. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  61. package/src/hooks/usePreviewPersistence.ts +15 -0
  62. package/src/hooks/useRazorSplit.test.tsx +48 -0
  63. package/src/hooks/useRazorSplit.ts +12 -1
  64. package/src/hooks/useRenderClipContent.test.ts +6 -4
  65. package/src/hooks/useRenderClipContent.ts +6 -3
  66. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  67. package/src/hooks/useTimelineEditGuard.ts +54 -0
  68. package/src/hooks/useTimelineEditing.ts +36 -55
  69. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  70. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  71. package/src/index.ts +1 -0
  72. package/src/player/components/AudioWaveform.test.tsx +30 -3
  73. package/src/player/components/AudioWaveform.tsx +6 -7
  74. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  75. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  76. package/src/player/components/ClipContextMenu.tsx +115 -90
  77. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  78. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  79. package/src/player/components/TimelineClip.test.tsx +17 -0
  80. package/src/player/components/TimelineClip.tsx +6 -0
  81. package/src/player/components/audioClipLink.test.ts +228 -31
  82. package/src/player/components/audioClipLink.ts +72 -25
  83. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  84. package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
  85. package/src/player/components/clipMenuAudioItems.tsx +31 -23
  86. package/src/player/components/clipMenuDuck.ts +5 -0
  87. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  88. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  89. package/src/player/components/clipMenuToolItems.tsx +1 -3
  90. package/src/player/components/clipSync.test.ts +67 -0
  91. package/src/player/components/clipSync.ts +80 -0
  92. package/src/player/components/linkLabelColor.test.ts +20 -0
  93. package/src/player/components/linkLabelColor.ts +24 -0
  94. package/src/player/components/timelineCallbacks.ts +11 -0
  95. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  96. package/src/player/components/timelineClipDragTypes.ts +2 -0
  97. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  98. package/src/player/components/timelineLinkSelection.ts +23 -0
  99. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  100. package/src/player/components/useTimelineClipDrag.ts +21 -0
  101. package/src/player/components/useTimelineProviderState.tsx +3 -0
  102. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  103. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  104. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  105. package/src/player/lib/timelineDOM.test.ts +31 -0
  106. package/src/player/lib/timelineDOM.ts +13 -0
  107. package/src/player/store/timelineElement.ts +12 -0
  108. package/src/styles/components.css +27 -11
  109. package/src/styles/theme.css +0 -1
  110. package/src/utils/gsapSoftReload.test.ts +238 -2
  111. package/src/utils/gsapSoftReload.ts +89 -74
  112. package/src/utils/gsapUndoRestore.test.ts +4 -3
  113. package/src/utils/gsapUndoRestore.ts +15 -0
  114. package/src/utils/linkedClipPreferences.ts +34 -0
  115. package/src/utils/softReloadTargets.ts +128 -0
  116. package/src/utils/studioUiPreferences.ts +8 -0
  117. package/dist/chunk-AOWVEVEU.js.map +0 -1
  118. 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); sound and picture end in their own divider. */
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
- setSelectedElementId(elementKey);
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
- setSelectedElementId(id);
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,