@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.
- package/dist/assets/{hyperframes-player-CJYtikAy.js → hyperframes-player-DvJeHBHi.js} +1 -1
- package/dist/assets/{index-CMSgrT2F.js → index--G6c4Q_x.js} +1 -1
- package/dist/assets/{index-HTFVHUJ4.js → index-Bt4vDkfw.js} +1 -1
- package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-CYP6dMd7.js → index-SFktf7O1.js} +205 -204
- package/dist/index.d.ts +22 -6
- package/dist/index.html +2 -2
- package/dist/index.js +5352 -4755
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaLinkEdits.test.ts +19 -6
- package/src/components/editor/mediaLinkEdits.ts +34 -13
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +9 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
- package/src/hooks/timelineLinkEditPlan.ts +6 -1
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useRenderClipContent.ts +3 -5
- package/src/hooks/useTimelineEditing.ts +13 -0
- package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
- package/src/hooks/useTimelineLinkEditing.ts +5 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
- package/src/player/components/ClipContextMenu.tsx +1 -1
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +41 -0
- package/src/player/components/TimelineClip.tsx +3 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +60 -0
- package/src/player/components/audioClipLink.ts +77 -0
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
- package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
- package/src/player/components/clipMenuAudioItems.tsx +16 -37
- package/src/player/components/clipMenuLinkItems.test.ts +84 -0
- package/src/player/components/clipMenuLinkItems.tsx +54 -9
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +79 -6
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +6 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipGestureHandlers.ts +26 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +20 -2
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipDrag.ts +12 -5
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +0 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/studioPendingEdits.test.ts +18 -0
- package/src/utils/typingTarget.test.ts +8 -0
- 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
|
-
|
|
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("
|
|
118
|
-
it("
|
|
119
|
-
expect(
|
|
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("
|
|
145
|
-
expect(
|
|
146
|
-
|
|
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("
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 {
|
|
158
|
-
expect(
|
|
159
|
-
expect(
|
|
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
|
|
149
|
-
|
|
150
|
-
if (
|
|
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
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
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(
|