@hyperframes/studio 0.8.108 → 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.
Files changed (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  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 +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  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/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -83,8 +83,14 @@ interface RenderLanesOptions {
83
83
  hoveredClip?: string | null;
84
84
  renderClipContent?: React.ComponentProps<typeof TimelineLanes>["renderClipContent"];
85
85
  snapGuide?: { time: number; type: "beat" | "clip-edge" | "playhead" } | null;
86
+ withoutSelectHandler?: boolean;
86
87
  }
87
88
 
89
+ const selectHandlerFor = (
90
+ options: RenderLanesOptions,
91
+ handler: (element: TimelineElement | null) => void,
92
+ ) => (options.withoutSelectHandler ? undefined : handler);
93
+
88
94
  function renderLanes(options: RenderLanesOptions = {}): {
89
95
  host: HTMLDivElement;
90
96
  root: Root;
@@ -163,7 +169,6 @@ function renderLanes(options: RenderLanesOptions = {}): {
163
169
  setResizingClip={vi.fn()}
164
170
  setDraggedClip={vi.fn()}
165
171
  setSelectedElementId={setSelectedElementId}
166
- shiftClickClipRef={createRef()}
167
172
  getPreviewElement={(el) => el}
168
173
  getTrackStyle={getTrackStyle}
169
174
  gsapAnimations={gsapAnimations}
@@ -174,7 +179,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
174
179
  onTogglePropertyGroupKeyframe={vi.fn()}
175
180
  onResizeElement={vi.fn()}
176
181
  onMoveElement={vi.fn()}
177
- onSelectElement={onSelectElement}
182
+ onSelectElement={selectHandlerFor(next, onSelectElement)}
178
183
  onRazorSplit={vi.fn()}
179
184
  onRazorSplitAll={vi.fn()}
180
185
  />,
@@ -439,6 +444,44 @@ describe("TimelineLanes selection", () => {
439
444
  expect(view.onSelectElement).toHaveBeenCalledWith(selected);
440
445
  act(() => view.root.unmount());
441
446
  });
447
+
448
+ it.each([["shiftKey"], ["metaKey"], ["ctrlKey"]])(
449
+ "%s-click adds a clip to the selection instead of replacing it",
450
+ (modifier) => {
451
+ const first = element("clip-a", TRACK_A);
452
+ const second = element("clip-b", TRACK_B);
453
+ usePlayerStore.getState().setElements([first, second]);
454
+ usePlayerStore.getState().setSelection([first.id], first.id);
455
+ const view = renderLanes({ elements: [first, second] });
456
+ const clipB = view.host.querySelector('[data-el-id="clip-b"]');
457
+ const click = (init: MouseEventInit) =>
458
+ act(() => clipB?.dispatchEvent(new MouseEvent("click", { bubbles: true, ...init })));
459
+
460
+ click({ [modifier]: true });
461
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a", "clip-b"]));
462
+ expect(view.setSelectedElementId).not.toHaveBeenCalled();
463
+ expect(view.onSelectElement).toHaveBeenLastCalledWith(second);
464
+
465
+ click({ [modifier]: true });
466
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a"]));
467
+ expect(view.onSelectElement).toHaveBeenLastCalledWith(first);
468
+ act(() => view.root.unmount());
469
+ },
470
+ );
471
+ it("Cmd-click adds a clip in a host that passes no onSelectElement", () => {
472
+ const first = element("clip-a", TRACK_A);
473
+ const second = element("clip-b", TRACK_B);
474
+ usePlayerStore.getState().setElements([first, second]);
475
+ usePlayerStore.getState().setSelection([first.id], first.id);
476
+ const view = renderLanes({ elements: [first, second], withoutSelectHandler: true });
477
+ act(() =>
478
+ view.host
479
+ .querySelector('[data-el-id="clip-b"]')
480
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true, metaKey: true })),
481
+ );
482
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a", "clip-b"]));
483
+ act(() => view.root.unmount());
484
+ });
442
485
  });
443
486
 
444
487
  describe("TimelineLanes clip thumbnails", () => {
@@ -77,7 +77,6 @@ export function TimelineLanes({
77
77
  setResizingClip,
78
78
  setDraggedClip,
79
79
  setSelectedElementId,
80
- shiftClickClipRef,
81
80
  getPreviewElement,
82
81
  getTrackStyle,
83
82
  keyframeCache,
@@ -404,7 +403,6 @@ export function TimelineLanes({
404
403
  onRazorSplit,
405
404
  onRazorSplitAll,
406
405
  blockedClipRef,
407
- shiftClickClipRef,
408
406
  suppressClickRef,
409
407
  scrollRef,
410
408
  setShowPopover,
@@ -1,3 +1,4 @@
1
+ import { TimelineAudioGainOverlay } from "./AudioGainDialog";
1
2
  import { useEffect, useMemo } from "react";
2
3
  import type { TimelineElement } from "../store/playerStore";
3
4
  import { EditPopover } from "./EditModal";
@@ -222,6 +223,7 @@ export function TimelineOverlays() {
222
223
  <TimelineKeyframeMenuOverlay />
223
224
  <TimelineClipMenuOverlay />
224
225
  <TimelineGapMenuOverlay />
226
+ <TimelineAudioGainOverlay />
225
227
  </>
226
228
  );
227
229
  }
@@ -1,34 +1,291 @@
1
+ // @vitest-environment happy-dom
1
2
  import { describe, expect, it } from "vitest";
2
- import { audioPillFlags, isLinkedVideoAudio } from "./audioClipLink";
3
-
4
- const audio = { id: "bed", tag: "audio", src: "assets/City Ride.mp4?v=2" };
5
-
6
- describe("isLinkedVideoAudio", () => {
7
- it("links an audio pill to a video of the same file", () => {
8
- expect(
9
- isLinkedVideoAudio(audio, [
10
- audio,
11
- { id: "picture", tag: "video", src: "/preview/assets/city ride.mp4" },
12
- ]),
13
- ).toBe(true);
14
- });
15
-
16
- it("greys a hidden clip and a muted group without dropping the link", () => {
17
- expect(audioPillFlags({ ...audio, hidden: true }, [])).toEqual({
18
- muted: true,
19
- linked: false,
20
- });
21
- expect(audioPillFlags({ ...audio, audioGroupHidden: true }, [])).toEqual({
22
- muted: true,
23
- linked: false,
24
- });
25
- });
26
-
27
- it("leaves a standalone audio file unlinked", () => {
28
- expect(
29
- isLinkedVideoAudio({ id: "vo", tag: "audio", src: "assets/voice.wav" }, [
30
- { id: "picture", tag: "video", src: "assets/city.mp4" },
31
- ]),
32
- ).toBe(false);
3
+ import {
4
+ audioPillFlags,
5
+ expandToLinkedMembers,
6
+ dropMisalignedTrimPartners,
7
+ linkedGestureKeys,
8
+ linkedMembersOf,
9
+ mediaAssetIdentity,
10
+ } from "./audioClipLink";
11
+ import { createTimelineElementFromManifestClip, parseTimelineFromDOM } from "../lib/timelineDOM";
12
+
13
+ const video = { id: "talk", link: "lk-1" };
14
+ const audio = { id: "talk-audio", link: "lk-1" };
15
+ const other = { id: "music", link: "lk-2" };
16
+ const otherAudio = { id: "music-audio", link: "lk-2" };
17
+ const plain = { id: "title" };
18
+ const elements = [video, audio, other, otherAudio, plain];
19
+
20
+ describe("audioPillFlags", () => {
21
+ it("greys a hidden clip and a muted group", () => {
22
+ expect(audioPillFlags({ hidden: true }).muted).toBe(true);
23
+ expect(audioPillFlags({ audioGroupHidden: true }).muted).toBe(true);
24
+ });
25
+ });
26
+
27
+ describe("mediaAssetIdentity", () => {
28
+ it("tells same-named files in different folders apart", () => {
29
+ expect(mediaAssetIdentity({ src: "assets/one/talk.mp4" })).not.toBe(
30
+ mediaAssetIdentity({ src: "assets/two/talk.mp4" }),
31
+ );
32
+ });
33
+
34
+ it("resolves a src against its own source file and drops query and hash", () => {
35
+ expect(mediaAssetIdentity({ src: "../assets/talk.mp4?v=2", sourceFile: "scenes/a.html" })).toBe(
36
+ mediaAssetIdentity({ src: "./assets/talk.mp4#t=1" }),
37
+ );
38
+ expect(mediaAssetIdentity({ src: "assets/City%20Ride.mp4" })).toBe(
39
+ mediaAssetIdentity({ src: "assets/City Ride.mp4" }),
40
+ );
41
+ expect(mediaAssetIdentity({ src: "" })).toBeNull();
42
+ });
43
+
44
+ it("keeps a remote url's query, since it can select a different file", () => {
45
+ expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1" })).not.toBe(
46
+ mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=2" }),
47
+ );
48
+ expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a%26x=b" })).not.toBe(
49
+ mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a&x=b" }),
50
+ );
51
+ expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4%3Fclip=a" })).not.toBe(
52
+ mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a" }),
53
+ );
54
+ expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1#t=3" })).toBe(
55
+ mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1" }),
56
+ );
57
+ });
58
+ });
59
+
60
+ describe("expandToLinkedMembers", () => {
61
+ it("adds every partner of a linked clip", () => {
62
+ expect(expandToLinkedMembers(["talk"], elements)).toEqual(new Set(["talk", "talk-audio"]));
63
+ });
64
+
65
+ it("leaves unlinked clips alone and does not pull in other groups", () => {
66
+ expect(expandToLinkedMembers(["title"], elements)).toEqual(new Set(["title"]));
67
+ expect(expandToLinkedMembers(["talk-audio", "title"], elements)).toEqual(
68
+ new Set(["talk-audio", "talk", "title"]),
69
+ );
70
+ });
71
+
72
+ it("prefers the store key over the id", () => {
73
+ const keyed = [
74
+ { id: "a", key: "k-a", link: "lk-9" },
75
+ { id: "b", key: "k-b", link: "lk-9" },
76
+ ];
77
+ expect(expandToLinkedMembers(["k-a"], keyed)).toEqual(new Set(["k-a", "k-b"]));
78
+ });
79
+ });
80
+
81
+ describe("link groups stay inside their composition", () => {
82
+ const root = [
83
+ { id: "v", link: "lk-1", sourceFile: undefined },
84
+ { id: "a", link: "lk-1", sourceFile: undefined },
85
+ ];
86
+ const child = [
87
+ { id: "cv", key: "child.html#cv", link: "lk-1", sourceFile: "child.html" },
88
+ { id: "ca", key: "child.html#ca", link: "lk-1", sourceFile: "child.html" },
89
+ ];
90
+ const all = [...root, ...child];
91
+
92
+ it("does not pull a child file's same-id link into the root group", () => {
93
+ expect(linkedMembersOf(root[0] ?? video, all).map((el) => el.id)).toEqual(["v", "a"]);
94
+ expect(expandToLinkedMembers(["child.html#cv"], all)).toEqual(
95
+ new Set(["child.html#cv", "child.html#ca"]),
96
+ );
97
+ });
98
+
99
+ const media = (id: string, tag: string, start: number) =>
100
+ `<${tag} id="${id}" class="clip" src="${id}.mp4" data-link="lk-1" data-start="${start}" data-duration="4"></${tag}>`;
101
+ const inlineDoc = () => {
102
+ const doc = document.implementation.createHTMLDocument();
103
+ doc.body.innerHTML = `<div data-composition-id="main" data-duration="20">
104
+ ${media("v", "video", 0)}${media("a", "audio", 0)}
105
+ <div id="child" data-composition-id="child" data-start="0" data-duration="10">
106
+ ${media("cv", "video", 5)}${media("ca", "audio", 5)}
107
+ </div>
108
+ </div>`;
109
+ return doc;
110
+ };
111
+
112
+ it("does not pull an inline composition's same-id link into the root group", () => {
113
+ const rows = parseTimelineFromDOM(inlineDoc(), 20).filter((row) => row.link);
114
+ const byId = (id: string) => rows.find((row) => row.domId === id) ?? video;
115
+ expect(linkedMembersOf(byId("v"), rows).map((row) => row.domId)).toEqual(["v", "a"]);
116
+ expect(linkedMembersOf(byId("cv"), rows).map((row) => row.domId)).toEqual(["cv", "ca"]);
117
+ });
118
+
119
+ it("keeps the inline composition apart on rows built from the runtime manifest", () => {
120
+ const doc = inlineDoc();
121
+ const rows = ["v", "a", "cv", "ca"].map((id, fallbackIndex) =>
122
+ createTimelineElementFromManifestClip({
123
+ clip: {
124
+ ...{ id, label: id, start: 0, duration: 4, track: 0, kind: "video", tagName: "video" },
125
+ ...{
126
+ compositionId: null,
127
+ parentCompositionId: null,
128
+ compositionSrc: null,
129
+ assetUrl: null,
130
+ },
131
+ },
132
+ fallbackIndex,
133
+ doc,
134
+ hostEl: doc.getElementById(id),
135
+ }),
136
+ );
137
+ const [rootVideo] = rows;
138
+ expect(linkedMembersOf(rootVideo ?? video, rows).map((row) => row.domId)).toEqual(["v", "a"]);
139
+ });
140
+ });
141
+
142
+ describe("linkedMembersOf", () => {
143
+ it("returns the group, or the clip alone when unlinked", () => {
144
+ expect(linkedMembersOf(video, elements).map((el) => el.id)).toEqual(["talk", "talk-audio"]);
145
+ expect(linkedMembersOf(plain, elements)).toEqual([plain]);
146
+ });
147
+ });
148
+
149
+ describe("linkedGestureKeys", () => {
150
+ it("drags an unselected linked clip with its partner", () => {
151
+ expect(linkedGestureKeys(new Set(["title"]), video, elements, false)).toEqual(
152
+ new Set(["talk", "talk-audio"]),
153
+ );
154
+ });
155
+
156
+ it("keeps a selection that holds the grabbed clip and adds partners", () => {
157
+ expect(linkedGestureKeys(new Set(["talk", "title"]), video, elements, false)).toEqual(
158
+ new Set(["talk", "talk-audio", "title"]),
159
+ );
160
+ });
161
+
162
+ it("Linked Selection off edits the grabbed clip alone without Alt", () => {
163
+ expect(linkedGestureKeys(new Set(["talk"]), video, elements, false, false)).toEqual(
164
+ new Set(["talk"]),
165
+ );
166
+ });
167
+
168
+ it("Alt edits the grabbed clip alone", () => {
169
+ expect(linkedGestureKeys(new Set(["talk", "talk-audio"]), video, elements, true)).toEqual(
170
+ new Set(["talk"]),
171
+ );
172
+ });
173
+ });
174
+
175
+ describe("selectClipWithLinks", () => {
176
+ const linkedPair = [
177
+ { id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
178
+ { id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
179
+ { id: "title", tag: "div", start: 0, duration: 4, track: 2 },
180
+ ];
181
+
182
+ async function store() {
183
+ const { usePlayerStore } = await import("../store/playerStore");
184
+ const { selectClipWithLinks } = await import("./timelineLinkSelection");
185
+ usePlayerStore.getState().setElements(linkedPair);
186
+ const click = (key: string, alt = false) =>
187
+ selectClipWithLinks(key, alt, usePlayerStore.getState().setSelectedElementId);
188
+ return { usePlayerStore, click };
189
+ }
190
+
191
+ it("a click selects the whole pair; Alt-click then narrows to one member", async () => {
192
+ const { usePlayerStore, click } = await store();
193
+ click("talk");
194
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk", "talk-audio"]));
195
+ click("talk-audio", true);
196
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk-audio"]));
197
+ });
198
+
199
+ it("a plain click collapses a larger selection to the clicked clip's link group", async () => {
200
+ const { usePlayerStore, click } = await store();
201
+ usePlayerStore.getState().setSelection(["talk", "talk-audio", "title"], "title");
202
+ click("talk");
203
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk", "talk-audio"]));
204
+ expect(usePlayerStore.getState().selectedElementId).toBe("talk");
205
+ });
206
+ });
207
+
208
+ describe("toggleClipWithLinks", () => {
209
+ const clips = [
210
+ { id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
211
+ { id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
212
+ { id: "title", tag: "div", start: 0, duration: 4, track: 2 },
213
+ { id: "music", tag: "audio", start: 0, duration: 4, track: 3 },
214
+ ];
215
+
216
+ async function store() {
217
+ const { usePlayerStore } = await import("../store/playerStore");
218
+ const { toggleClipWithLinks } = await import("./timelineLinkSelection");
219
+ usePlayerStore.getState().setElements(clips);
220
+ usePlayerStore.getState().setSelection(["title"], "title");
221
+ return { usePlayerStore, toggle: toggleClipWithLinks };
222
+ }
223
+
224
+ it("adds an unlinked clip to the selection and makes it primary", async () => {
225
+ const { usePlayerStore, toggle } = await store();
226
+ expect(toggle("music", false)?.id).toBe("music");
227
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title", "music"]));
228
+ expect(usePlayerStore.getState().selectedElementId).toBe("music");
229
+ });
230
+
231
+ it("adding a linked clip adds its partner too", async () => {
232
+ const { usePlayerStore, toggle } = await store();
233
+ toggle("talk-audio", false);
234
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(
235
+ new Set(["title", "talk", "talk-audio"]),
236
+ );
237
+ });
238
+
239
+ it("toggling a selected clip removes it and its partner, keeping the rest", async () => {
240
+ const { usePlayerStore, toggle } = await store();
241
+ toggle("talk", false);
242
+ expect(toggle("talk", false)?.id).toBe("title");
243
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title"]));
244
+ expect(toggle("title", false)).toBeNull();
245
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
246
+ });
247
+
248
+ it("Alt adds only the clicked member of a linked pair", async () => {
249
+ const { usePlayerStore, toggle } = await store();
250
+ toggle("talk", true);
251
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title", "talk"]));
252
+ });
253
+ });
254
+
255
+ describe("Linked Selection off", () => {
256
+ const linkedPair = [
257
+ { id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
258
+ { id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
259
+ ];
260
+
261
+ it("expands and lists nothing past the clip itself", () => {
262
+ expect(expandToLinkedMembers(["talk"], linkedPair, false)).toEqual(new Set(["talk"]));
263
+ expect(linkedMembersOf(linkedPair[0], linkedPair, false)).toEqual([linkedPair[0]]);
264
+ });
265
+
266
+ it("a click selects only the clicked clip", async () => {
267
+ const { usePlayerStore } = await import("../store/playerStore");
268
+ const { selectClipWithLinks } = await import("./timelineLinkSelection");
269
+ const { useLinkedClipPreferences } = await import("../../utils/linkedClipPreferences");
270
+ usePlayerStore.getState().setElements(linkedPair);
271
+ useLinkedClipPreferences.getState().setLinkedSelection(false);
272
+ try {
273
+ selectClipWithLinks("talk", false, usePlayerStore.getState().setSelectedElementId);
274
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk"]));
275
+ } finally {
276
+ useLinkedClipPreferences.getState().setLinkedSelection(true);
277
+ }
278
+ });
279
+ });
280
+
281
+ describe("dropMisalignedTrimPartners", () => {
282
+ const v = { id: "v", link: "lk", start: 2, duration: 6 };
283
+ const a = { id: "a", link: "lk", start: 3, duration: 5 };
284
+ const keys = new Set(["v", "a"]);
285
+ it("keeps a partner whose grabbed edge is at the same time", () => {
286
+ expect(dropMisalignedTrimPartners(keys, v, [v, a], "end")).toEqual(keys);
287
+ });
288
+ it("trims only the grabbed clip when the partner's edge is elsewhere", () => {
289
+ expect(dropMisalignedTrimPartners(keys, v, [v, a], "start")).toEqual(new Set(["v"]));
33
290
  });
34
291
  });
@@ -1,33 +1,157 @@
1
- import type { TimelineElement } from "../store/timelineElement";
1
+ import { sameCompositionScope, type TimelineElement } from "../store/timelineElement";
2
+ import { syncPartnerOf } from "./clipSync";
2
3
 
3
- function mediaFileKey(src: string | undefined): string | null {
4
+ type LinkScoped = Pick<TimelineElement, "sourceFile" | "compositionScope">;
5
+ type LinkedElement = Pick<TimelineElement, "id" | "key" | "link"> & LinkScoped;
6
+
7
+ const keyOf = (element: Pick<TimelineElement, "id" | "key">) => element.key ?? element.id;
8
+
9
+ const SOURCE_BASE = "https://project.invalid/";
10
+ const SOURCE_ORIGIN = new URL(SOURCE_BASE).origin;
11
+
12
+ export function mediaAssetIdentity(
13
+ element: Pick<TimelineElement, "src" | "sourceFile">,
14
+ ): string | null {
15
+ const src = element.src?.trim();
4
16
  if (!src) return null;
5
- const path = src.split(/[?#]/, 1)[0] ?? "";
6
- const segment = path.split(/[/\\]/).pop()?.trim().toLowerCase() ?? "";
7
- return segment.length > 0 ? segment : null;
17
+ try {
18
+ const url = new URL(src, new URL(element.sourceFile ?? "index.html", SOURCE_BASE));
19
+ if (url.origin !== SOURCE_ORIGIN) return `${url.origin}${url.pathname}${url.search}`;
20
+ return decodeURIComponent(`${url.origin}${url.pathname}`);
21
+ } catch {
22
+ return null;
23
+ }
8
24
  }
9
25
 
10
- export function audioPillFlags(
11
- audio: Pick<TimelineElement, "id" | "tag" | "src" | "hidden" | "audioGroupHidden">,
12
- elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
13
- ): { muted: boolean; linked: boolean } {
14
- return {
15
- muted: audio.hidden === true || audio.audioGroupHidden === true,
16
- linked: isLinkedVideoAudio(audio, elements),
17
- };
18
- }
19
-
20
- /** An audio pill whose file is also a video clip on this timeline. */
21
- export function isLinkedVideoAudio(
22
- audio: Pick<TimelineElement, "id" | "tag" | "src">,
23
- elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
26
+ function isLinked(element: Pick<TimelineElement, "link">): boolean {
27
+ return typeof element.link === "string" && element.link.length > 0;
28
+ }
29
+
30
+ export function sharesLinkGroup(
31
+ a: Pick<TimelineElement, "link"> & LinkScoped,
32
+ b: Pick<TimelineElement, "link"> & LinkScoped,
24
33
  ): boolean {
25
- if (audio.tag.trim().toLowerCase() !== "audio") return false;
26
- const key = mediaFileKey(audio.src);
27
- if (!key) return false;
28
- return elements.some((element) => {
29
- if (element.id === audio.id) return false;
30
- if (element.tag.trim().toLowerCase() !== "video") return false;
31
- return mediaFileKey(element.src) === key;
32
- });
34
+ return isLinked(a) && a.link === b.link && sameCompositionScope(a, b);
35
+ }
36
+
37
+ export function audioPillFlags(
38
+ audio: Pick<TimelineElement, "hidden" | "audioGroupHidden">,
39
+ _elements?: readonly unknown[],
40
+ ): { muted: boolean } {
41
+ return { muted: audio.hidden === true || audio.audioGroupHidden === true };
42
+ }
43
+
44
+ export function linkedMembersOf<T extends LinkedElement>(
45
+ element: T,
46
+ elements: readonly T[],
47
+ linked = true,
48
+ ): T[] {
49
+ if (!linked || !isLinked(element)) return [element];
50
+ const members = elements.filter((candidate) => sharesLinkGroup(candidate, element));
51
+ return members.some((member) => keyOf(member) === keyOf(element))
52
+ ? members
53
+ : [element, ...members];
54
+ }
55
+
56
+ export function expandToLinkedMembers(
57
+ keys: Iterable<string>,
58
+ elements: readonly LinkedElement[],
59
+ linked = true,
60
+ ): Set<string> {
61
+ const expanded = new Set(keys);
62
+ if (!linked) return expanded;
63
+ const seeds = elements.filter((el) => expanded.has(keyOf(el)) && isLinked(el));
64
+ for (const element of elements) {
65
+ if (seeds.some((seed) => sharesLinkGroup(seed, element))) expanded.add(keyOf(element));
66
+ }
67
+ return expanded;
68
+ }
69
+
70
+ export function linkedGestureKeys(
71
+ selected: ReadonlySet<string>,
72
+ grabbed: LinkedElement,
73
+ elements: readonly LinkedElement[],
74
+ altKey: boolean,
75
+ linked = true,
76
+ ): Set<string> {
77
+ const grabbedKey = keyOf(grabbed);
78
+ if (altKey) return new Set([grabbedKey]);
79
+ const base = selected.has(grabbedKey) ? selected : [grabbedKey];
80
+ return expandToLinkedMembers(base, elements, linked);
81
+ }
82
+
83
+ type TimedLinked = LinkedElement & Pick<TimelineElement, "start" | "duration">;
84
+
85
+ const edgeTime = (el: Pick<TimelineElement, "start" | "duration">, edge: "start" | "end") =>
86
+ edge === "start" ? el.start : el.start + el.duration;
87
+
88
+ export function dropMisalignedTrimPartners(
89
+ keys: ReadonlySet<string>,
90
+ grabbed: TimedLinked,
91
+ elements: readonly TimedLinked[],
92
+ edge: "start" | "end",
93
+ ): Set<string> {
94
+ const kept = new Set(keys);
95
+ if (!isLinked(grabbed)) return kept;
96
+ for (const el of elements) {
97
+ if (!sharesLinkGroup(el, grabbed) || keyOf(el) === keyOf(grabbed)) continue;
98
+ if (Math.abs(edgeTime(el, edge) - edgeTime(grabbed, edge)) > 1e-3) kept.delete(keyOf(el));
99
+ }
100
+ return kept;
101
+ }
102
+
103
+ type BoundedElement = Pick<
104
+ TimelineElement,
105
+ | "id"
106
+ | "key"
107
+ | "tag"
108
+ | "start"
109
+ | "duration"
110
+ | "link"
111
+ | "syncOrigin"
112
+ | "playbackStart"
113
+ | "playbackRate"
114
+ | "sourceFile"
115
+ >;
116
+
117
+ const tagOf = (el: Pick<TimelineElement, "tag">) => el.tag.trim().toLowerCase();
118
+
119
+ function partnerVideoBounds(
120
+ audio: BoundedElement,
121
+ elements: readonly BoundedElement[],
122
+ ): { videoKey: string; start: number; end: number } | null {
123
+ if (tagOf(audio) !== "audio") return null;
124
+ const linkedVideo = isLinked(audio)
125
+ ? elements.find((el) => sharesLinkGroup(el, audio) && tagOf(el) === "video")
126
+ : undefined;
127
+ const video = linkedVideo ?? syncPartnerOf(audio, elements);
128
+ return video
129
+ ? { videoKey: keyOf(video), start: video.start, end: video.start + video.duration }
130
+ : null;
131
+ }
132
+
133
+ export function heldAudioShiftRange(
134
+ movers: readonly BoundedElement[],
135
+ elements: readonly BoundedElement[],
136
+ moving: ReadonlySet<string>,
137
+ ): { min: number; max: number } {
138
+ let min = Number.NEGATIVE_INFINITY;
139
+ let max = Number.POSITIVE_INFINITY;
140
+ for (const mover of movers) {
141
+ const bounds = heldPartnerVideoBounds(mover, elements, moving);
142
+ if (!bounds) continue;
143
+ const low = bounds.start - mover.start;
144
+ min = Math.max(min, low);
145
+ max = Math.min(max, Math.max(low, bounds.end - mover.duration - mover.start));
146
+ }
147
+ return { min, max };
148
+ }
149
+
150
+ export function heldPartnerVideoBounds(
151
+ audio: BoundedElement,
152
+ elements: readonly BoundedElement[],
153
+ gestureKeys: ReadonlySet<string>,
154
+ ): { start: number; end: number } | null {
155
+ const bounds = partnerVideoBounds(audio, elements);
156
+ return bounds && !gestureKeys.has(bounds.videoKey) ? bounds : null;
33
157
  }