@hyperframes/studio 0.8.109 → 0.8.111

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 (93) hide show
  1. package/dist/assets/{hyperframes-player-CJYtikAy.js → hyperframes-player-DvJeHBHi.js} +1 -1
  2. package/dist/assets/{index-CMSgrT2F.js → index--G6c4Q_x.js} +1 -1
  3. package/dist/assets/{index-HTFVHUJ4.js → index-Bt4vDkfw.js} +1 -1
  4. package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-CYP6dMd7.js → index-SFktf7O1.js} +205 -204
  6. package/dist/index.d.ts +22 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5352 -4755
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +8 -8
  12. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  13. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  14. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  15. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  16. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  17. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  19. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  20. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  21. package/src/components/editor/marqueeCommit.ts +16 -8
  22. package/src/components/editor/mediaLinkEdits.test.ts +19 -6
  23. package/src/components/editor/mediaLinkEdits.ts +34 -13
  24. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  25. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  26. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  27. package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
  28. package/src/contexts/TimelineEditContext.tsx +1 -0
  29. package/src/editorGesturesExports.test.tsx +1 -0
  30. package/src/hooks/appHotkeysDispatch.ts +9 -0
  31. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  32. package/src/hooks/timelineElementsAttribute.ts +113 -0
  33. package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
  34. package/src/hooks/timelineLinkEditPlan.ts +6 -1
  35. package/src/hooks/useAppHotkeys.test.ts +34 -0
  36. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  37. package/src/hooks/useRenderClipContent.ts +3 -5
  38. package/src/hooks/useTimelineEditing.ts +13 -0
  39. package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
  40. package/src/hooks/useTimelineLinkEditing.ts +5 -0
  41. package/src/index.ts +5 -0
  42. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  43. package/src/player/components/AudioGainDialog.tsx +226 -0
  44. package/src/player/components/ClipBadges.test.tsx +32 -8
  45. package/src/player/components/ClipBadges.tsx +37 -31
  46. package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
  47. package/src/player/components/ClipContextMenu.tsx +1 -1
  48. package/src/player/components/ClipPeakMarks.tsx +2 -14
  49. package/src/player/components/Timeline.test.ts +15 -0
  50. package/src/player/components/TimelineClip.test.tsx +41 -0
  51. package/src/player/components/TimelineClip.tsx +3 -2
  52. package/src/player/components/TimelineLanes.test.tsx +45 -2
  53. package/src/player/components/TimelineLanes.tsx +0 -2
  54. package/src/player/components/TimelineOverlays.tsx +2 -0
  55. package/src/player/components/audioClipLink.test.ts +60 -0
  56. package/src/player/components/audioClipLink.ts +77 -0
  57. package/src/player/components/audioGainApply.test.ts +87 -0
  58. package/src/player/components/audioGainApply.ts +57 -0
  59. package/src/player/components/audioGainDialogStore.ts +36 -0
  60. package/src/player/components/audioGainPlan.test.ts +82 -0
  61. package/src/player/components/audioGainPlan.ts +53 -0
  62. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
  63. package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
  64. package/src/player/components/clipMenuAudioItems.tsx +16 -37
  65. package/src/player/components/clipMenuLinkItems.test.ts +84 -0
  66. package/src/player/components/clipMenuLinkItems.tsx +54 -9
  67. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  68. package/src/player/components/clipMenuToolItems.tsx +79 -6
  69. package/src/player/components/clipPeakMap.ts +46 -0
  70. package/src/player/components/clipPeakRuns.test.ts +14 -1
  71. package/src/player/components/clipPeakRuns.ts +11 -0
  72. package/src/player/components/clipToolAttrs.test.ts +57 -41
  73. package/src/player/components/clipToolAttrs.ts +48 -45
  74. package/src/player/components/studioShortcuts.ts +2 -0
  75. package/src/player/components/timelineCallbacks.ts +6 -0
  76. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  77. package/src/player/components/timelineClipDragPreview.ts +32 -7
  78. package/src/player/components/timelineClipGestureHandlers.ts +26 -31
  79. package/src/player/components/timelineEditing.test.ts +0 -13
  80. package/src/player/components/timelineEditing.ts +0 -11
  81. package/src/player/components/timelineLaneProps.ts +0 -5
  82. package/src/player/components/timelineLinkSelection.ts +20 -2
  83. package/src/player/components/timelineViewportGeometry.ts +3 -0
  84. package/src/player/components/useClipToolState.ts +0 -5
  85. package/src/player/components/useTimelineClipDrag.ts +12 -5
  86. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  87. package/src/player/components/useTimelineProviderState.tsx +0 -2
  88. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  89. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  90. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  91. package/src/utils/studioPendingEdits.test.ts +18 -0
  92. package/src/utils/typingTarget.test.ts +8 -0
  93. package/src/utils/typingTarget.ts +1 -0
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { clipPeakRuns } from "./clipPeakRuns";
2
+ import { clipPeakRuns, clipSourcePeak } from "./clipPeakRuns";
3
3
 
4
4
  const map = (bins: number[]) => ({ binSeconds: 1, bins });
5
5
 
@@ -36,3 +36,16 @@ describe("clipPeakRuns", () => {
36
36
  expect(clipPeakRuns(map([1]), { mediaStart: 0, sourceSpan: 0 }, 1).runs).toEqual([]);
37
37
  });
38
38
  });
39
+
40
+ describe("clipSourcePeak", () => {
41
+ it("is the loudest bin inside the played source window only", () => {
42
+ const bins = map([0.9, 0.2, 0.4, 0.3, 1]);
43
+ expect(clipSourcePeak(bins, { mediaStart: 1, sourceSpan: 3 })).toBe(0.4);
44
+ expect(clipSourcePeak(bins, { mediaStart: 0, sourceSpan: 5 })).toBe(1);
45
+ });
46
+
47
+ it("is null for an empty window or one past the file's end", () => {
48
+ expect(clipSourcePeak(map([0.5]), { mediaStart: 0, sourceSpan: 0 })).toBeNull();
49
+ expect(clipSourcePeak(map([0.5]), { mediaStart: 4, sourceSpan: 2 })).toBeNull();
50
+ });
51
+ });
@@ -48,6 +48,17 @@ export function clipPeakRuns(
48
48
  return { runs, peakDbfs: runs.length > 0 ? 20 * Math.log10(loudest) : null };
49
49
  }
50
50
 
51
+ export function clipSourcePeak(map: PeakMap, window: ClipSourceWindow): number | null {
52
+ if (!(window.sourceSpan > 0) || !(map.binSeconds > 0)) return null;
53
+ const first = Math.max(0, Math.floor(window.mediaStart / map.binSeconds));
54
+ const last = Math.min(
55
+ map.bins.length,
56
+ Math.ceil((window.mediaStart + window.sourceSpan) / map.binSeconds),
57
+ );
58
+ if (last <= first) return null;
59
+ return map.bins.slice(first, last).reduce((loudest, bin) => Math.max(loudest, bin), 0);
60
+ }
61
+
51
62
  export function isPeakMap(value: unknown): value is PeakMap {
52
63
  if (typeof value !== "object" || value === null) return false;
53
64
  const bins: unknown = Reflect.get(value, "bins");
@@ -5,11 +5,12 @@ import {
5
5
  activeLook,
6
6
  activeVoicePreset,
7
7
  chainWithVoicePreset,
8
+ clipSpeedSuffix,
8
9
  hasCrop,
9
10
  isDucked,
11
+ clipVolumeBadge,
10
12
  lookAttrValue,
11
- readClipBadges,
12
- splitVisibleBadges,
13
+ readClipEffects,
13
14
  type ClipToolState,
14
15
  } from "./clipToolAttrs";
15
16
 
@@ -98,7 +99,6 @@ const baseState: ClipToolState = {
98
99
  colorGrading: null,
99
100
  clipPath: null,
100
101
  carve: null,
101
- link: null,
102
102
  };
103
103
 
104
104
  const rampAutomation = JSON.stringify({
@@ -114,48 +114,64 @@ const rampAutomation = JSON.stringify({
114
114
  ],
115
115
  });
116
116
 
117
- describe("readClipBadges", () => {
118
- it("shows nothing for a plain clip at 100%", () => {
119
- expect(readClipBadges(baseState)).toEqual([]);
120
- });
121
-
122
- it("lists every applied tool in the wireframe's order", () => {
123
- const labels = readClipBadges({
124
- ...baseState,
125
- volume: 1.8,
126
- fxChain: chainWithVoicePreset(null, "voice-clean"),
127
- automation: rampAutomation,
128
- colorGrading: lookAttrValue("warm-daylight"),
129
- clipPath: "inset(0px 20px)",
130
- carve: "{}",
131
- link: "talk",
132
- }).map((b) => b.label);
133
- expect(labels).toEqual([
134
- "Linked",
135
- "Warm daylight",
136
- "Voice: Clean",
137
- "Ramp",
138
- "Crop",
139
- "Ducked",
140
- "180%",
141
- ]);
117
+ describe("readClipEffects", () => {
118
+ it("lists nothing for a plain clip, so the fx badge stays grey", () => {
119
+ expect(readClipEffects(baseState)).toEqual([]);
120
+ expect(readClipEffects({ ...baseState, automation: rampAutomation, volume: 1.8 })).toEqual([]);
142
121
  });
143
122
 
144
- it("badges a muted audio clip but not muted b-roll", () => {
145
- expect(readClipBadges({ ...baseState, tag: "audio", muted: true })[0]?.label).toBe("Muted");
146
- expect(readClipBadges({ ...baseState, hasSound: false, muted: true })).toEqual([]);
123
+ it("lists every applied effect in the tooltip's order", () => {
124
+ expect(
125
+ readClipEffects({
126
+ ...baseState,
127
+ fxChain: chainWithVoicePreset(null, "voice-clean"),
128
+ colorGrading: lookAttrValue("warm-daylight"),
129
+ clipPath: "inset(0px 20px)",
130
+ carve: "{}",
131
+ }),
132
+ ).toEqual(["Look: Warm daylight", "Voice: Clean", "Crop", "Ducked"]);
147
133
  });
148
134
 
149
- it("caps at two visible badges plus the link badge", () => {
150
- const badges = readClipBadges({
151
- ...baseState,
152
- link: "talk",
153
- colorGrading: lookAttrValue("mono-clean"),
154
- clipPath: "inset(5px)",
155
- volume: 0.6,
135
+ it("names a hand-added effect and skips disabled and carve-generated nodes", () => {
136
+ const chain = serializeAudioFxChain({
137
+ version: 1,
138
+ nodes: [
139
+ { id: "a", type: "highpass", enabled: true, params: { frequency: 80 } },
140
+ { id: "b", type: "gain", enabled: false, params: {} },
141
+ { id: "c", type: "gain", enabled: true, fromCarve: true, params: {} },
142
+ ],
156
143
  });
157
- const { visible, hidden } = splitVisibleBadges(badges);
158
- expect(visible.map((b) => b.label)).toEqual(["Linked", "Mono", "Crop"]);
159
- expect(hidden.map((b) => b.label)).toEqual(["60%"]);
144
+ const effects = readClipEffects({ ...baseState, fxChain: chain });
145
+ expect(effects).toHaveLength(1);
146
+ expect(effects[0]).not.toBe("highpass");
147
+ });
148
+
149
+ it("reads a hand-made grading without a menu preset as a Look", () => {
150
+ expect(readClipEffects({ ...baseState, colorGrading: '{"adjust":{"contrast":0.2}}' })).toEqual([
151
+ "Look",
152
+ ]);
153
+ });
154
+ });
155
+
156
+ describe("clipVolumeBadge", () => {
157
+ it("shows volume off 100% and Muted only on a clip with sound", () => {
158
+ expect(clipVolumeBadge({ ...baseState, volume: 1.8 })).toBe("180%");
159
+ expect(clipVolumeBadge(baseState)).toBeNull();
160
+ expect(clipVolumeBadge({ ...baseState, tag: "audio", muted: true })).toBe("Muted");
161
+ expect(clipVolumeBadge({ ...baseState, hasSound: false, muted: true })).toBeNull();
162
+ });
163
+ });
164
+
165
+ describe("clipSpeedSuffix", () => {
166
+ it("shows a constant speed as a percentage and hides 100%", () => {
167
+ expect(clipSpeedSuffix(1.5, null)).toBe(" [150%]");
168
+ expect(clipSpeedSuffix(0.35, null)).toBe(" [35%]");
169
+ expect(clipSpeedSuffix(1, null)).toBe("");
170
+ expect(clipSpeedSuffix(undefined, null)).toBe("");
171
+ });
172
+
173
+ it("shows a rate lane as a ramp, whatever the base rate", () => {
174
+ expect(clipSpeedSuffix(2, rampAutomation)).toBe(" [ramp]");
175
+ expect(clipSpeedSuffix(1, rampAutomation)).toBe(" [ramp]");
160
176
  });
161
177
  });
@@ -4,11 +4,13 @@
4
4
  */
5
5
 
6
6
  import {
7
+ getAudioFxDef,
7
8
  parseAudioFxChain,
8
9
  serializeAudioFxChain,
9
10
  type HfAudioFxChain,
11
+ type HfAudioFxNode,
10
12
  } from "@hyperframes/core/audio-fx";
11
- import { activeAudioFxPresetIds } from "@hyperframes/core/audio-fx-presets";
13
+ import { activeAudioFxPresetIds, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
12
14
  import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
13
15
  import { parseRateLane } from "@hyperframes/core/speed-ramp";
14
16
  import { applyPresetToChain } from "../../components/editor/useApplyAudioFxPreset";
@@ -117,6 +119,17 @@ function hasRateRamp(rawAutomation: string | null | undefined): boolean {
117
119
  return parseRateLane(rawAutomation) !== null;
118
120
  }
119
121
 
122
+ /** Premiere's speed cue on the clip name: ` [ramp]` under a rate lane, else ` [150%]` off 100%. */
123
+ export function clipSpeedSuffix(
124
+ playbackRate: number | null | undefined,
125
+ rawAutomation: string | null | undefined,
126
+ ): string {
127
+ if (hasRateRamp(rawAutomation)) return " [ramp]";
128
+ const rate = playbackRate ?? 1;
129
+ if (!Number.isFinite(rate) || Math.abs(rate - 1) < 0.005) return "";
130
+ return ` [${Math.round(rate * 100)}%]`;
131
+ }
132
+
120
133
  const ZERO_INSET = /^inset\(\s*0(px|%)?\s*\)$/i;
121
134
 
122
135
  export function hasCrop(clipPath: string | null | undefined): boolean {
@@ -124,13 +137,6 @@ export function hasCrop(clipPath: string | null | undefined): boolean {
124
137
  return value !== "" && value !== "none" && !ZERO_INSET.test(value);
125
138
  }
126
139
 
127
- export type ClipBadgeKind = "link" | "look" | "voice" | "ramp" | "crop" | "ducked" | "volume";
128
-
129
- export interface ClipBadge {
130
- kind: ClipBadgeKind;
131
- label: string;
132
- }
133
-
134
140
  /** What a clip's attributes say is applied; the element's own attributes, as read off its node. */
135
141
  export interface ClipToolState {
136
142
  tag: string;
@@ -142,46 +148,43 @@ export interface ClipToolState {
142
148
  colorGrading: string | null;
143
149
  clipPath: string | null;
144
150
  carve: string | null;
145
- link: string | null;
146
151
  }
147
152
 
148
- function volumeBadge(state: ClipToolState): ClipBadge | null {
149
- const isAudio = state.tag === "audio";
150
- if (!isAudio && !state.hasSound) return null;
151
- if (state.muted) return { kind: "volume", label: "Muted" };
153
+ export function clipVolumeBadge(state: ClipToolState): string | null {
154
+ if (state.tag !== "audio" && !state.hasSound) return null;
155
+ if (state.muted) return "Muted";
152
156
  const volume = state.volume ?? 1;
153
157
  if (Math.abs(volume - 1) < 0.005) return null;
154
- return { kind: "volume", label: `${Math.round(volume * 100)}%` };
155
- }
156
-
157
- /** Badges in the wireframe's order: look, voice, ramp, crop, ducked, volume. The link badge leads. */
158
- export function readClipBadges(state: ClipToolState): ClipBadge[] {
159
- const look = lookLabel(activeLook(state.colorGrading));
160
- const voice = voicePresetLabel(activeVoicePreset(state.fxChain));
161
- const volume = volumeBadge(state);
162
- const badges: Array<ClipBadge | null> = [
163
- state.link ? { kind: "link", label: "Linked" } : null,
164
- look ? { kind: "look", label: look } : null,
165
- voice ? { kind: "voice", label: `Voice: ${voice}` } : null,
166
- hasRateRamp(state.automation) ? { kind: "ramp", label: "Ramp" } : null,
167
- hasCrop(state.clipPath) ? { kind: "crop", label: "Crop" } : null,
168
- isDucked(state.carve) ? { kind: "ducked", label: "Ducked" } : null,
169
- volume,
158
+ return `${Math.round(volume * 100)}%`;
159
+ }
160
+
161
+ function lookEffect(rawGrading: string | null): string | null {
162
+ const grading = normalizeHfColorGrading(parseJson(rawGrading));
163
+ if (!grading) return null;
164
+ const label = lookLabel(grading.preset ?? null);
165
+ return label ? `Look: ${label}` : "Look";
166
+ }
167
+
168
+ function presetEffect(presetId: string): string {
169
+ const voice = voicePresetLabel(presetId);
170
+ return voice ? `Voice: ${voice}` : (getAudioFxPreset(presetId)?.label ?? presetId);
171
+ }
172
+
173
+ function chainNodeEffect(node: HfAudioFxNode): string | null {
174
+ if (node.enabled === false || node.fromCarve) return null;
175
+ if (node.fromPreset) return presetEffect(node.fromPreset);
176
+ if (node.fromEq) return "EQ";
177
+ if (node.fromLeveller) return "Leveller";
178
+ return node.label ?? getAudioFxDef(node.type)?.label ?? node.type;
179
+ }
180
+
181
+ /** Every effect on the clip, in the fx badge tooltip's order; empty means a grey badge. */
182
+ export function readClipEffects(state: ClipToolState): string[] {
183
+ const effects: Array<string | null> = [
184
+ lookEffect(state.colorGrading),
185
+ ...parseChainOrEmpty(state.fxChain).nodes.map(chainNodeEffect),
186
+ hasCrop(state.clipPath) ? "Crop" : null,
187
+ isDucked(state.carve) ? "Ducked" : null,
170
188
  ];
171
- return badges.filter((badge): badge is ClipBadge => badge !== null);
172
- }
173
-
174
- const MAX_VISIBLE_BADGES = 2;
175
-
176
- /** The link badge is always shown; the rest cap at two, then `+N`. */
177
- export function splitVisibleBadges(badges: readonly ClipBadge[]): {
178
- visible: ClipBadge[];
179
- hidden: ClipBadge[];
180
- } {
181
- const link = badges.filter((badge) => badge.kind === "link");
182
- const rest = badges.filter((badge) => badge.kind !== "link");
183
- return {
184
- visible: [...link, ...rest.slice(0, MAX_VISIBLE_BADGES)],
185
- hidden: rest.slice(MAX_VISIBLE_BADGES),
186
- };
189
+ return [...new Set(effects.filter((effect): effect is string => effect !== null))];
187
190
  }
@@ -4,6 +4,7 @@ export const STUDIO_PLAIN_KEYS = {
4
4
  split: "s",
5
5
  record: "r",
6
6
  addKeyframe: "k",
7
+ audioGain: "g",
7
8
  } as const;
8
9
 
9
10
  export interface ShortcutHint {
@@ -51,6 +52,7 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
51
52
  { key: "⌘V", label: "Paste element" },
52
53
  { key: "⌘X", label: "Cut element" },
53
54
  { key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
55
+ { key: hintKey(STUDIO_PLAIN_KEYS.audioGain), label: "Audio Gain for clips with sound" },
54
56
  { key: "⇧Click", label: "Razor tool: split all tracks" },
55
57
  { key: "[", label: "Select clips starting before the playhead" },
56
58
  { key: "]", label: "Select clips running at or after the playhead" },
@@ -122,6 +122,12 @@ export interface TimelineEditCallbacks {
122
122
  value: string | null,
123
123
  label: string,
124
124
  ) => Promise<TimelineEditOutcome | void>;
125
+ /** One attribute on several clips, saved as one undo step. */
126
+ onSetElementsAttributeQuiet?: (
127
+ edits: ReadonlyArray<{ element: TimelineElement; value: string | null }>,
128
+ attr: string,
129
+ label: string,
130
+ ) => Promise<TimelineEditOutcome | void>;
125
131
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
126
132
  onLinkEdit?: (edit: TimelineLinkEdit) => Promise<void> | void;
127
133
  onDeleteElementOnly?: (element: TimelineElement) => Promise<void> | void;
@@ -726,3 +726,99 @@ describe("a nested clip's drop stops at its host's start in the preview", () =>
726
726
  expect(await saved(early, 1)).toBe(1);
727
727
  });
728
728
  });
729
+
730
+ describe("an audio clip stays inside its partner video", () => {
731
+ const video = (extra: Partial<TimelineElement> = {}) => ({
732
+ ...clip("v", 0, 10, 20, 1),
733
+ syncOrigin: "lk-1",
734
+ ...extra,
735
+ });
736
+ const audio = (extra: Partial<TimelineElement> = {}) => ({
737
+ ...clip("a", 1, 12, 8, 0, "audio"),
738
+ syncOrigin: "lk-1",
739
+ ...extra,
740
+ });
741
+ const audioCtx = (elements: TimelineElement[], selected: string[] = []) => ({
742
+ ...ctx(undefined, elements),
743
+ trackOrder: [0, 1],
744
+ audioTracks: new Set([1]),
745
+ selectedKeys: new Set(selected),
746
+ });
747
+
748
+ it("a drag past the video's end stops with the audio's end on it", () => {
749
+ const a = audio();
750
+ const { drag, clientX, clientY } = horizontalDrag(a, 1.5, 30);
751
+ const next = computeDragPreview(drag, clientX, clientY, audioCtx([video(), a]));
752
+ expect(next.previewStart).toBe(22);
753
+ expect(next.pointerClientX).toBe(drag.originClientX + 10 * PPS);
754
+ });
755
+
756
+ it("a drag before the video's start stops at it, linked or same-source", () => {
757
+ const linked = audio({ syncOrigin: undefined, link: "lk-9" });
758
+ const { drag, clientX, clientY } = horizontalDrag(linked, 1.5, -10);
759
+ const elements = [video({ syncOrigin: undefined, link: "lk-9" }), linked];
760
+ expect(computeDragPreview(drag, clientX, clientY, audioCtx(elements)).previewStart).toBe(10);
761
+ });
762
+
763
+ it("moves freely with no partner video, or when the video moves with it", () => {
764
+ const loose = audio({ syncOrigin: undefined });
765
+ const free = horizontalDrag(loose, 1.5, 30);
766
+ expect(
767
+ computeDragPreview(free.drag, free.clientX, free.clientY, audioCtx([video(), loose]))
768
+ .previewStart,
769
+ ).toBe(42);
770
+ const a = audio();
771
+ const both = horizontalDrag(a, 1.5, 30);
772
+ expect(
773
+ computeDragPreview(both.drag, both.clientX, both.clientY, audioCtx([video(), a], ["v", "a"]))
774
+ .previewStart,
775
+ ).toBe(42);
776
+ });
777
+
778
+ it("rejects a drop that collision placement would push outside the video", () => {
779
+ const a = audio();
780
+ const obstacle = clip("o", 1, 20, 10, 0, "audio");
781
+ const { drag, clientX, clientY } = horizontalDrag(a, 1.5, 10);
782
+ const next = computeDragPreview(drag, clientX, clientY, audioCtx([video(), a, obstacle]));
783
+ expect(next.previewStart).toBeGreaterThanOrEqual(10);
784
+ expect(next.previewStart + a.duration).toBeLessThanOrEqual(30);
785
+ });
786
+
787
+ it("keeps an audio carried in a multi-selection inside its video", () => {
788
+ const a = audio();
789
+ const title = clip("t", 0, 40, 4, 0, "text");
790
+ const { drag, clientX, clientY } = horizontalDrag(title, 0.5, 30);
791
+ const elements = [video(), a, title];
792
+ const next = computeDragPreview(drag, clientX, clientY, audioCtx(elements, ["t", "a"]));
793
+ expect(next.previewStart).toBe(50);
794
+ });
795
+
796
+ const trim = (edge: "start" | "end", deltaSeconds: number, gestureKeys: string[] = []) =>
797
+ computeResizePreview(
798
+ {
799
+ element: audio({ sourceDuration: 100, playbackStart: 20 }),
800
+ edge,
801
+ originClientX: 0,
802
+ previewStart: 12,
803
+ previewDuration: 8,
804
+ started: true,
805
+ },
806
+ deltaSeconds * 100,
807
+ {
808
+ scroll: fakeScroll(),
809
+ pps: 100,
810
+ buildSnapTargets: () => [],
811
+ elements: [video(), audio()],
812
+ gestureKeys: new Set(["a", ...gestureKeys]),
813
+ },
814
+ );
815
+
816
+ it("a trim cannot extend past the video's start or end", () => {
817
+ expect(trim("end", 50)).toMatchObject({ previewStart: 12, previewDuration: 18 });
818
+ expect(trim("start", -10)).toMatchObject({ previewStart: 10, previewDuration: 10 });
819
+ });
820
+
821
+ it("a trim carried by the video too is not held to the video's old span", () => {
822
+ expect(trim("end", 50, ["v"]).previewDuration).toBe(58);
823
+ });
824
+ });
@@ -23,6 +23,7 @@ import {
23
23
  import { groupMoveFloor, resolveGroupMovers } from "./timelineMultiDragPreview";
24
24
  import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
25
25
  import { STUDIO_PREVIEW_FPS } from "../lib/time";
26
+ import { heldAudioShiftRange, heldPartnerVideoBounds } from "./audioClipLink";
26
27
 
27
28
  /** Snap-target builder closure supplied by the hook (closes over refs + store). */
28
29
  type BuildSnapTargets = (
@@ -111,6 +112,7 @@ interface GroupDrag {
111
112
  obstacles: TimelineElement[];
112
113
  /** Lowest start the grabbed clip may take (see groupMoveFloor). */
113
114
  floor: number;
115
+ moving: ReadonlySet<string>;
114
116
  }
115
117
 
116
118
  function resolveGroupDrag(drag: DraggedClipState, ctx: DragPreviewContext): GroupDrag {
@@ -119,14 +121,20 @@ function resolveGroupDrag(drag: DraggedClipState, ctx: DragPreviewContext): Grou
119
121
  ctx.selectedKeys,
120
122
  drag.element.key ?? drag.element.id,
121
123
  );
122
- if (!movers) return { obstacles: ctx.elements, floor: groupMoveFloor(drag.element, []) };
124
+ if (!movers) {
125
+ const moving = new Set([drag.element.key ?? drag.element.id]);
126
+ return { obstacles: ctx.elements, floor: groupMoveFloor(drag.element, []), moving };
127
+ }
123
128
  const moving = new Set(movers.map((e) => e.key ?? e.id));
124
129
  return {
125
130
  obstacles: ctx.elements.filter((e) => !moving.has(e.key ?? e.id)),
126
131
  floor: groupMoveFloor(drag.element, movers),
132
+ moving,
127
133
  };
128
134
  }
129
135
 
136
+ const SHIFT_EPSILON_S = 1e-6;
137
+
130
138
  /** Recompute the dragged-clip preview (move + snap + group clamp + drop placement). */
131
139
  export function computeDragPreview(
132
140
  drag: DraggedClipState,
@@ -179,13 +187,25 @@ export function computeDragPreview(
179
187
  );
180
188
  // A group moves rigidly: the grabbed clip stops where any mover would cross its host's start.
181
189
  const group = resolveGroupDrag(drag, ctx);
182
- const previewStart = Math.max(snap.start, group.floor);
190
+ const dragKey = drag.element.key ?? drag.element.id;
191
+ const passengers = ctx.elements.filter(
192
+ (el) => (el.key ?? el.id) !== dragKey && group.moving.has(el.key ?? el.id),
193
+ );
194
+ const movers = [drag.element, ...passengers];
195
+ const shift = heldAudioShiftRange(movers, ctx.elements, group.moving);
196
+ const origin = drag.element.start;
197
+ const floored = Math.max(snap.start, group.floor);
198
+ const previewStart = origin + Math.max(shift.min, Math.min(floored - origin, shift.max));
183
199
  const placement = resolveDropPlacement(drag, clientY, previewStart, nextMove.track, ctx, group);
200
+ const placedShift = placement.start - origin;
201
+ if (placedShift < shift.min - SHIFT_EPSILON_S || placedShift > shift.max + SHIFT_EPSILON_S) {
202
+ return { ...drag, started: true };
203
+ }
184
204
  const { track: previewTrack, insertRow } = placement;
185
205
  return {
186
206
  ...drag,
187
207
  started: true,
188
- pointerClientX: clientX,
208
+ pointerClientX: clientX - (floored - previewStart) * pps,
189
209
  pointerClientY: clientY,
190
210
  previewStart: placement.start,
191
211
  previewTrack,
@@ -210,6 +230,8 @@ export interface ResizePreviewContext {
210
230
  scroll: HTMLDivElement | null;
211
231
  pps: number;
212
232
  buildSnapTargets: BuildSnapTargets;
233
+ elements?: readonly TimelineElement[];
234
+ gestureKeys?: ReadonlySet<string>;
213
235
  }
214
236
 
215
237
  export interface ResizePreviewResult {
@@ -253,14 +275,18 @@ export function computeResizePreview(
253
275
  // rightward and, after a far move, collapsed a clip to the sliver between its
254
276
  // start and the comp end (the 8s→0.95s audio incident). Images/text/shapes
255
277
  // have no source, so they extend freely.
256
- const maxEnd = resize.element.start + sourceRemaining;
278
+ const video = ctx.elements
279
+ ? heldPartnerVideoBounds(resize.element, ctx.elements, ctx.gestureKeys ?? new Set())
280
+ : null;
281
+ const minStart = Math.max(clampToHostStart(resize.element, 0), video?.start ?? 0);
282
+ const maxEnd = Math.min(resize.element.start + sourceRemaining, video?.end ?? Infinity);
257
283
  let nextResize = resolveTimelineResize(
258
284
  {
259
285
  start: resize.element.start,
260
286
  duration: resize.element.duration,
261
287
  originClientX: resize.originClientX,
262
288
  pixelsPerSecond: pps,
263
- minStart: clampToHostStart(resize.element, 0),
289
+ minStart,
264
290
  maxEnd,
265
291
  playbackStart:
266
292
  resize.edge === "start" && canSeedPlaybackStart
@@ -303,8 +329,7 @@ export function computeResizePreview(
303
329
  const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
304
330
  const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
305
331
  const delta = snapped - nextResize.start;
306
- const floor = clampToHostStart(resize.element, 0);
307
- const bounds = clipStartTrimDeltaBounds(clip, floor, resolveTimelineMinDuration());
332
+ const bounds = clipStartTrimDeltaBounds(clip, minStart, resolveTimelineMinDuration());
308
333
  if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
309
334
  if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
310
335
  snap = target;
@@ -8,7 +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
+ import { selectClipWithLinks, toggleClipWithLinks } from "./timelineLinkSelection";
12
12
  import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
13
13
 
14
14
  export interface ClipGestureDeps {
@@ -18,11 +18,6 @@ export interface ClipGestureDeps {
18
18
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
19
19
  onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
20
20
  blockedClipRef: React.RefObject<BlockedClipState | null>;
21
- shiftClickClipRef: React.RefObject<{
22
- element: TimelineElement;
23
- anchorX: number;
24
- anchorY: number;
25
- } | null>;
26
21
  suppressClickRef: React.RefObject<boolean>;
27
22
  scrollRef: React.RefObject<HTMLDivElement | null>;
28
23
  setShowPopover(v: boolean): void;
@@ -45,9 +40,11 @@ function canStartResize(
45
40
  return capabilities.canTrimEnd;
46
41
  }
47
42
 
43
+ const isAdditiveClick = (e: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }) =>
44
+ e.shiftKey || e.metaKey || e.ctrlKey;
45
+
48
46
  type PointerDownAction =
49
47
  | { kind: "ignore" }
50
- | { kind: "arm-shift-click" }
51
48
  | { kind: "block"; intent: BlockedTimelineEditIntent; rect: DOMRect }
52
49
  | { kind: "move"; rect: DOMRect };
53
50
 
@@ -82,7 +79,7 @@ function resolvePointerDownAction(
82
79
  ): PointerDownAction {
83
80
  if (e.button !== 0) return { kind: "ignore" };
84
81
  if (usePlayerStore.getState().activeTool === "razor") return { kind: "ignore" };
85
- if (e.shiftKey) return { kind: "arm-shift-click" };
82
+ if (isAdditiveClick(e)) return { kind: "ignore" };
86
83
 
87
84
  const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
88
85
  const intent = resolveBlockedTimelineEditIntent({
@@ -119,7 +116,6 @@ export function createClipGestureHandlers(
119
116
  onRazorSplit,
120
117
  onRazorSplitAll,
121
118
  blockedClipRef,
122
- shiftClickClipRef,
123
119
  suppressClickRef,
124
120
  scrollRef,
125
121
  setShowPopover,
@@ -154,11 +150,6 @@ export function createClipGestureHandlers(
154
150
  const action = resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement);
155
151
  if (action.kind === "ignore") return;
156
152
 
157
- if (action.kind === "arm-shift-click") {
158
- shiftClickClipRef.current = { element: el, anchorX: e.clientX, anchorY: e.clientY };
159
- return;
160
- }
161
-
162
153
  if (action.kind === "block") {
163
154
  blockedClipRef.current = {
164
155
  pointerId: e.pointerId,
@@ -197,26 +188,30 @@ export function createClipGestureHandlers(
197
188
  });
198
189
  };
199
190
 
191
+ const razorSplit = (e: ReactMouseEvent, split: NonNullable<typeof onRazorSplit>): void => {
192
+ const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
193
+ const splitTime = previewElement.start + (e.clientX - clipRect.left) / pps;
194
+ const clampedTime = Math.max(
195
+ previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
196
+ Math.min(
197
+ previewElement.start + previewElement.duration - SPLIT_BOUNDARY_EPSILON_S,
198
+ splitTime,
199
+ ),
200
+ );
201
+ if (e.shiftKey && onRazorSplitAll) onRazorSplitAll(clampedTime);
202
+ else split(el, clampedTime);
203
+ };
204
+
200
205
  const onClick = (e: ReactMouseEvent): void => {
201
206
  e.stopPropagation();
202
207
  if (suppressClickRef.current) return;
203
- const { activeTool } = usePlayerStore.getState();
204
- if (activeTool === "razor" && onRazorSplit) {
205
- const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
206
- const clickOffsetX = e.clientX - clipRect.left;
207
- const splitTime = previewElement.start + clickOffsetX / pps;
208
- const clampedTime = Math.max(
209
- previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
210
- Math.min(
211
- previewElement.start + previewElement.duration - SPLIT_BOUNDARY_EPSILON_S,
212
- splitTime,
213
- ),
214
- );
215
- if (e.shiftKey && onRazorSplitAll) {
216
- onRazorSplitAll(clampedTime);
217
- } else {
218
- onRazorSplit(el, clampedTime);
219
- }
208
+ if (usePlayerStore.getState().activeTool === "razor" && onRazorSplit) {
209
+ razorSplit(e, onRazorSplit);
210
+ return;
211
+ }
212
+ if (isAdditiveClick(e)) {
213
+ const primary = toggleClipWithLinks(elementKey, e.altKey);
214
+ onSelectElement?.(primary);
220
215
  return;
221
216
  }
222
217
  // Clip selection is idempotent; empty timeline space owns deselection.
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
- buildClipRangeSelection,
4
3
  buildPromptCopyText,
5
4
  buildTimelineElementAgentPrompt,
6
5
  buildTimelineAgentPrompt,
@@ -607,18 +606,6 @@ describe("resolveBlockedTimelineEditIntent", () => {
607
606
  });
608
607
  });
609
608
 
610
- describe("buildClipRangeSelection", () => {
611
- it("anchors the full clip range at the click position", () => {
612
- expect(
613
- buildClipRangeSelection({ start: 1.25, duration: 3.5 }, { anchorX: 320, anchorY: 180 }),
614
- ).toEqual({
615
- start: 1.25,
616
- end: 4.75,
617
- anchorX: 320,
618
- anchorY: 180,
619
- });
620
- });
621
- });
622
609
  describe("resolveTimelineAutoScroll", () => {
623
610
  it("does not scroll when the pointer stays away from the edges", () => {
624
611
  expect(