@hyperframes/studio 0.8.46 → 0.8.48
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-CtVX6jL_.js +464 -0
- package/dist/assets/{index-CbchRscN.js → index-B3-orIjW.js} +1 -1
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/index-C9gJKpqQ.js +441 -0
- package/dist/assets/{index-BywY2OM3.js → index-D_j-0PD3.js} +1 -1
- package/dist/index.d.ts +83 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1118 -661
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/manualEditingAvailability.test.ts +19 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -5
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
- package/src/hooks/useSdkSession.ts +21 -3
- package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
- package/src/hooks/useTimelineAssetDropOps.ts +20 -16
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -4
- package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.test.ts +42 -0
- package/src/player/store/playerStore.ts +25 -28
- package/src/player/store/readinessSlice.ts +42 -0
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/sdkCutover.gate.test.ts +5 -4
- package/src/utils/sdkCutover.test.ts +93 -0
- package/src/utils/sdkCutover.ts +46 -25
- package/src/utils/sdkCutoverEligibility.ts +21 -0
- package/src/utils/sdkCutoverPolicy.test.ts +11 -2
- package/src/utils/sdkCutoverPolicy.ts +5 -3
- package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
- package/src/utils/sdkEditTransaction.ts +22 -1
- package/src/utils/sdkResolverShadow.ts +10 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
- package/dist/assets/index-DCblmTX3.js +0 -441
- package/dist/assets/index-DnRfAiK2.css +0 -1
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
type HfAutomationLane,
|
|
23
23
|
} from "@hyperframes/core/audio-automation";
|
|
24
24
|
import { parseAudioFxChain, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
25
|
-
import {
|
|
25
|
+
import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
|
|
26
26
|
import type { TimelineElement } from "../store/playerStore";
|
|
27
27
|
|
|
28
28
|
const EMPTY: HfAutomation = { version: 1, lanes: [] };
|
|
@@ -245,13 +245,14 @@ export interface AutomationLaneGroup {
|
|
|
245
245
|
* spectrum, top down), so the first clip to carry a property fixes its row and
|
|
246
246
|
* later clips only append properties nobody has shown yet.
|
|
247
247
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
248
|
+
* Gated on `isAudioOrVideoTimelineElement`, the same predicate `automationLaneCountOf`
|
|
249
|
+
* and `TimelineAutomationLaneSlot`'s clip filter use — the row's reserved height
|
|
250
|
+
* and its drawn lanes have to count the same clips.
|
|
250
251
|
*/
|
|
251
252
|
export function groupAutomationLanes(elements: readonly TimelineElement[]): AutomationLaneGroup[] {
|
|
252
253
|
const groups = new Map<string, AutomationLaneGroup>();
|
|
253
254
|
for (const element of elements) {
|
|
254
|
-
if (!
|
|
255
|
+
if (!isAudioOrVideoTimelineElement(element)) continue;
|
|
255
256
|
const chain = elementFxChain(element);
|
|
256
257
|
for (const lane of elementAutomationLanes(element)) {
|
|
257
258
|
const key = laneGroupKey(lane.target, chain);
|
|
@@ -23,6 +23,21 @@ import { getAudioFxDef, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
|
23
23
|
/** Points nearer than this in clip seconds are the same point, not two. */
|
|
24
24
|
export const POINT_MERGE_SEC = 0.02;
|
|
25
25
|
|
|
26
|
+
/** Insert or replace the point nearest `t` — the one merge/seed rule every
|
|
27
|
+
* hand-editing gesture on a lane uses (timeline double-click, panel control). */
|
|
28
|
+
export function mergeInsertPoint(
|
|
29
|
+
points: readonly HfAutomationLane["points"][number][],
|
|
30
|
+
t: number,
|
|
31
|
+
v: number,
|
|
32
|
+
): HfAutomationLane["points"] {
|
|
33
|
+
const kept = points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC);
|
|
34
|
+
// A lane's first point alone would be a constant, which is not what
|
|
35
|
+
// committing to an empty lane means: seed the far end at the same value so
|
|
36
|
+
// the envelope has somewhere to go.
|
|
37
|
+
const seeded = points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
|
|
38
|
+
return [...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t);
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
/**
|
|
27
42
|
* Closest two breakpoints may sit in clip seconds while still being two points.
|
|
28
43
|
*
|
|
@@ -56,7 +56,6 @@ function renderHarness(
|
|
|
56
56
|
api = useTimelineAssetDrop({
|
|
57
57
|
scrollRef: { current: scroll },
|
|
58
58
|
ppsRef: { current: 40 },
|
|
59
|
-
durationRef: { current: 120 },
|
|
60
59
|
trackOrderRef: { current: tracks },
|
|
61
60
|
rowGeometryRef: { current: geometry },
|
|
62
61
|
contentOrigin: 0,
|
|
@@ -138,7 +137,6 @@ describe("useTimelineAssetDrop", () => {
|
|
|
138
137
|
it("drops once on a model row outside the mounted window and appends below the last row", () => {
|
|
139
138
|
const onAssetDrop = vi.fn();
|
|
140
139
|
const view = renderHarness(onAssetDrop);
|
|
141
|
-
usePlayerStore.getState().setCurrentTime(12.5);
|
|
142
140
|
view.scroll.scrollTop = view.scroll.scrollHeight - view.scroll.clientHeight;
|
|
143
141
|
const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
|
|
144
142
|
|
|
@@ -148,11 +146,29 @@ describe("useTimelineAssetDrop", () => {
|
|
|
148
146
|
});
|
|
149
147
|
|
|
150
148
|
expect(onAssetDrop).toHaveBeenCalledTimes(1);
|
|
151
|
-
|
|
149
|
+
// pps=40, clientX=400 -> 10s at the pointer, not the playhead.
|
|
150
|
+
expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 10, track: 100 });
|
|
152
151
|
expect(view.api.isDragOver).toBe(false);
|
|
153
152
|
act(() => view.root.unmount());
|
|
154
153
|
});
|
|
155
154
|
|
|
155
|
+
it("places the drop at the pointer x, ignoring the playhead", () => {
|
|
156
|
+
const onAssetDrop = vi.fn();
|
|
157
|
+
const view = renderHarness(onAssetDrop);
|
|
158
|
+
usePlayerStore.getState().setCurrentTime(50);
|
|
159
|
+
const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
|
|
160
|
+
|
|
161
|
+
act(() => {
|
|
162
|
+
view.api.handleAssetDragOver(dragEvent(transfer, 80, 100));
|
|
163
|
+
view.api.handleAssetDrop(dragEvent(transfer, 80, 100));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// pps=40, clientX=80 -> 2s, far from the 50s playhead: proves start tracks
|
|
167
|
+
// the drop position, not usePlayerStore.currentTime.
|
|
168
|
+
expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 2, track: 0 });
|
|
169
|
+
act(() => view.root.unmount());
|
|
170
|
+
});
|
|
171
|
+
|
|
156
172
|
it("ignores malformed payloads and clears the actor on project reset", () => {
|
|
157
173
|
const onAssetDrop = vi.fn();
|
|
158
174
|
const view = renderHarness(onAssetDrop, 1);
|
|
@@ -190,7 +206,8 @@ describe("useTimelineAssetDrop", () => {
|
|
|
190
206
|
});
|
|
191
207
|
|
|
192
208
|
expect(onAssetDrop).not.toHaveBeenCalled();
|
|
193
|
-
|
|
209
|
+
// pps=40, clientX=400 -> 10s at the pointer.
|
|
210
|
+
expect(onBlockDrop).toHaveBeenCalledExactlyOnceWith("title-card", { start: 10, track: 0 });
|
|
194
211
|
act(() => view.root.unmount());
|
|
195
212
|
});
|
|
196
213
|
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
parseTimelineCompositionPayload,
|
|
5
5
|
TIMELINE_COMPOSITION_MIME,
|
|
6
6
|
} from "../../utils/timelineCompositionDrop";
|
|
7
|
-
import { usePlayerStore } from "../store/playerStore";
|
|
8
7
|
import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
|
|
9
8
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
10
9
|
import {
|
|
@@ -15,7 +14,6 @@ import {
|
|
|
15
14
|
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
|
16
15
|
scrollRef: RefObject<HTMLDivElement | null>;
|
|
17
16
|
ppsRef: RefObject<number>;
|
|
18
|
-
durationRef: RefObject<number>;
|
|
19
17
|
trackOrderRef: RefObject<number[]>;
|
|
20
18
|
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
|
21
19
|
contentOrigin: number;
|
|
@@ -53,11 +51,6 @@ function invokeDropCallback(callback: () => Promise<void> | void): void {
|
|
|
53
51
|
}
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
function resolveDropStart(usePointerStart: boolean, pointerStart: number): number {
|
|
57
|
-
if (usePointerStart) return pointerStart;
|
|
58
|
-
return Math.max(0, usePlayerStore.getState().currentTime);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
54
|
function applyFileDrop(
|
|
62
55
|
transfer: DataTransfer,
|
|
63
56
|
onFileDrop: TimelineDropCallbacks["onFileDrop"],
|
|
@@ -87,17 +80,14 @@ function applyTypedJsonDrop(
|
|
|
87
80
|
}
|
|
88
81
|
|
|
89
82
|
/**
|
|
90
|
-
* Dropping an asset/file/block onto the timeline places it at the
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* OS file drops and internal asset drops share this same placement path, so
|
|
95
|
-
* both land identically.
|
|
83
|
+
* Dropping an asset/file/block/composition onto the timeline places it at the
|
|
84
|
+
* exact time and track it was dropped on, like CapCut (pointer placement on
|
|
85
|
+
* every track but the magnetic main track). Supersedes the prior playhead
|
|
86
|
+
* decision (#2291); playhead adds stay available, see useAddAssetAtPlayhead.
|
|
96
87
|
*/
|
|
97
88
|
export function useTimelineAssetDrop({
|
|
98
89
|
scrollRef,
|
|
99
90
|
ppsRef,
|
|
100
|
-
durationRef,
|
|
101
91
|
trackOrderRef,
|
|
102
92
|
rowGeometryRef,
|
|
103
93
|
contentOrigin,
|
|
@@ -185,10 +175,10 @@ export function useTimelineAssetDrop({
|
|
|
185
175
|
);
|
|
186
176
|
|
|
187
177
|
const resolveDropPlacement = useCallback(
|
|
188
|
-
(clientX: number, clientY: number
|
|
178
|
+
(clientX: number, clientY: number): TimelinePlacement => {
|
|
189
179
|
const scroll = scrollRef.current;
|
|
190
180
|
const rect = scroll?.getBoundingClientRect();
|
|
191
|
-
|
|
181
|
+
return resolveTimelineAssetDrop(
|
|
192
182
|
{
|
|
193
183
|
rectLeft: rect?.left ?? 0,
|
|
194
184
|
rectTop: rect?.top ?? 0,
|
|
@@ -196,20 +186,14 @@ export function useTimelineAssetDrop({
|
|
|
196
186
|
scrollTop: scroll?.scrollTop ?? 0,
|
|
197
187
|
contentOrigin,
|
|
198
188
|
pixelsPerSecond: ppsRef.current,
|
|
199
|
-
duration: durationRef.current,
|
|
200
|
-
clampStartToDuration: !usePointerStart,
|
|
201
189
|
rowHeights: rowGeometryRef.current.rowHeights,
|
|
202
190
|
trackOrder: trackOrderRef.current,
|
|
203
191
|
},
|
|
204
192
|
clientX,
|
|
205
193
|
clientY,
|
|
206
194
|
);
|
|
207
|
-
return {
|
|
208
|
-
start: resolveDropStart(usePointerStart, pointer.start),
|
|
209
|
-
track: pointer.track,
|
|
210
|
-
};
|
|
211
195
|
},
|
|
212
|
-
[scrollRef, ppsRef,
|
|
196
|
+
[scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
|
|
213
197
|
);
|
|
214
198
|
|
|
215
199
|
const handleAssetDrop = useCallback(
|
|
@@ -218,15 +202,15 @@ export function useTimelineAssetDrop({
|
|
|
218
202
|
const canCommit = activeDropEpochRef.current === sessionEpoch;
|
|
219
203
|
clearDropPreview();
|
|
220
204
|
if (!canCommit) return;
|
|
205
|
+
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
|
206
|
+
|
|
221
207
|
const compositionPayload = parseTimelineCompositionPayload(
|
|
222
208
|
e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
|
|
223
209
|
);
|
|
224
210
|
if (compositionPayload && onCompositionDrop) {
|
|
225
|
-
const placement = resolveDropPlacement(e.clientX, e.clientY, true);
|
|
226
211
|
invokeDropCallback(() => onCompositionDrop(compositionPayload.sourcePath, placement));
|
|
227
212
|
return;
|
|
228
213
|
}
|
|
229
|
-
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
|
230
214
|
|
|
231
215
|
if (applyFileDrop(e.dataTransfer, onFileDrop, placement)) return;
|
|
232
216
|
if (applyTypedJsonDrop(e.dataTransfer, TIMELINE_ASSET_MIME, "path", onAssetDrop, placement)) {
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
canShiftTrackGapClips,
|
|
6
6
|
commitCloseAllTrackGaps,
|
|
7
7
|
commitCloseTrackGap,
|
|
8
|
+
resolveMainTrackDeleteRippleShifts,
|
|
9
|
+
resolveShiftedElements,
|
|
8
10
|
} from "./timelineGapCommit";
|
|
9
11
|
import { resolveAllTrackGaps } from "./timelineGaps";
|
|
10
12
|
|
|
@@ -150,6 +152,25 @@ describe("commitCloseAllTrackGaps", () => {
|
|
|
150
152
|
});
|
|
151
153
|
});
|
|
152
154
|
|
|
155
|
+
describe("resolveShiftedElements", () => {
|
|
156
|
+
it("maps each shift to its element and new start", () => {
|
|
157
|
+
const elements = [el("a", 0, 2), el("c", 8, 1)];
|
|
158
|
+
const resolved = resolveShiftedElements(elements, [{ key: "c", newStart: 2 }]);
|
|
159
|
+
expect(resolved).toEqual([{ element: elements[1], start: 2 }]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("resolves by key when present, falling back to id", () => {
|
|
163
|
+
const withKey: TimelineElement = { ...el("a", 0, 2), key: "a-key" };
|
|
164
|
+
const resolved = resolveShiftedElements([withKey], [{ key: "a-key", newStart: 5 }]);
|
|
165
|
+
expect(resolved).toEqual([{ element: withKey, start: 5 }]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("drops a shift whose key matches no element", () => {
|
|
169
|
+
const elements = [el("a", 0, 2)];
|
|
170
|
+
expect(resolveShiftedElements(elements, [{ key: "ghost", newStart: 0 }])).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
153
174
|
describe("canShiftTrackGapClips", () => {
|
|
154
175
|
it("is true only when every named clip is movable", () => {
|
|
155
176
|
const lane = [el("a", 1, 2), lockedEl("b", 5, 3)];
|
|
@@ -161,3 +182,78 @@ describe("canShiftTrackGapClips", () => {
|
|
|
161
182
|
expect(canShiftTrackGapClips([el("a", 0, 1)], [{ key: "ghost", newStart: 0 }])).toBe(false);
|
|
162
183
|
});
|
|
163
184
|
});
|
|
185
|
+
|
|
186
|
+
describe("resolveMainTrackDeleteRippleShifts", () => {
|
|
187
|
+
it("closes the gap left by a deleted main-track clip", () => {
|
|
188
|
+
const deleted = [el("b", 2, 2)];
|
|
189
|
+
const survivors = [el("a", 0, 2), el("c", 8, 1)]; // "b" already removed from this set
|
|
190
|
+
// "c" shifts left by exactly "b"'s 2s duration (8 -> 6), not all the way
|
|
191
|
+
// to 2: the 4-6..8 span was already an open gap before the delete, and
|
|
192
|
+
// ripple must not also swallow it.
|
|
193
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toEqual([
|
|
194
|
+
{ key: "c", newStart: 6 },
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("does not also close a pre-existing gap the delete never touched", () => {
|
|
199
|
+
// a(0-2), b(2-4)[deleted], GAP(4-6), c(6-7): deleting b should slide c
|
|
200
|
+
// left by b's 2s only, landing at 4 — flush against a, the pre-existing
|
|
201
|
+
// gap collapsed only because it's now adjacent, not doubly-counted.
|
|
202
|
+
const deleted = [el("b", 2, 2)];
|
|
203
|
+
const survivors = [el("a", 0, 2), el("c", 6, 1)];
|
|
204
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toEqual([
|
|
205
|
+
{ key: "c", newStart: 4 },
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("leaves an untouched clip before the delete point exactly where it was", () => {
|
|
210
|
+
// a(0-2), GAP(2-5), b(5-7)[deleted]: nothing follows b, so nothing
|
|
211
|
+
// shifts — a keeps its own leading position, ripple included.
|
|
212
|
+
const deleted = [el("b", 5, 2)];
|
|
213
|
+
const survivors = [el("a", 0, 2)];
|
|
214
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns null when ripple is off", () => {
|
|
218
|
+
const deleted = [el("b", 2, 2)];
|
|
219
|
+
const survivors = [el("a", 0, 2), el("c", 8, 1)];
|
|
220
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, false)).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns null when nothing deleted was on the main track", () => {
|
|
224
|
+
const deletedOverlay = [el("o", 2, 2, 1)]; // track 1, not main
|
|
225
|
+
const survivors = [el("a", 0, 2), el("c", 8, 1)];
|
|
226
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deletedOverlay, true)).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("returns null when the survivors are already gapless", () => {
|
|
230
|
+
const deleted = [el("b", 100, 1)]; // deleted from far off the end
|
|
231
|
+
const survivors = [el("a", 0, 2), el("c", 2, 1)]; // already contiguous
|
|
232
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("refuses the whole ripple (not a partial one) when a shifting survivor is locked", () => {
|
|
236
|
+
const deleted = [el("b", 2, 2)];
|
|
237
|
+
const survivors = [el("a", 0, 2), lockedEl("c", 8, 1)];
|
|
238
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("ignores audio and non-main-track survivors even when a main-track clip was deleted", () => {
|
|
242
|
+
const deleted = [el("b", 2, 2)];
|
|
243
|
+
const survivors = [el("a", 0, 2), el("c", 8, 1), el("overlay", 3, 1, 1)];
|
|
244
|
+
const shifts = resolveMainTrackDeleteRippleShifts(survivors, deleted, true);
|
|
245
|
+
expect(shifts).toEqual([{ key: "c", newStart: 6 }]); // "overlay" (track 1) untouched
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("sums only the deleted spans that precede each survivor, for a multi-select delete", () => {
|
|
249
|
+
// a(0-2), b(2-4)[deleted], c(4-6), d(6-8)[deleted], e(8-9): a marquee
|
|
250
|
+
// delete of b and d in one batch. "c" sits before d, so it shifts by
|
|
251
|
+
// b's 2s alone; "e" sits after both, so it shifts by their 4s combined.
|
|
252
|
+
const deleted = [el("b", 2, 2), el("d", 6, 2)];
|
|
253
|
+
const survivors = [el("a", 0, 2), el("c", 4, 2), el("e", 8, 1)];
|
|
254
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toEqual([
|
|
255
|
+
{ key: "c", newStart: 2 },
|
|
256
|
+
{ key: "e", newStart: 4 },
|
|
257
|
+
]);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -10,8 +10,11 @@ import {
|
|
|
10
10
|
resolveAllTrackGaps,
|
|
11
11
|
resolveCloseGapShifts,
|
|
12
12
|
resolveTrackGapAt,
|
|
13
|
+
round3,
|
|
14
|
+
TRACK_GAP_EPSILON_S,
|
|
13
15
|
type TrackGapShift,
|
|
14
16
|
} from "./timelineGaps";
|
|
17
|
+
import { isMainTrackElement } from "./timelineZones";
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* Commit layer for the track-gap context menu ("Close gap" / "Close all gaps").
|
|
@@ -35,16 +38,29 @@ const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
|
35
38
|
// timelineClipDragCommit.ts.
|
|
36
39
|
let gapCloseGestureSeq = 0;
|
|
37
40
|
|
|
38
|
-
/**
|
|
41
|
+
/** Resolve each shift to its named element, dropping any shift whose key
|
|
42
|
+
* matches nothing — the type stays honest without a non-null assertion. */
|
|
43
|
+
export function resolveShiftedElements(
|
|
44
|
+
elements: readonly TimelineElement[],
|
|
45
|
+
shifts: readonly TrackGapShift[],
|
|
46
|
+
): Array<{ element: TimelineElement; start: number }> {
|
|
47
|
+
const byKey = new Map(elements.map((e) => [keyOf(e), e]));
|
|
48
|
+
return shifts.flatMap((s) => {
|
|
49
|
+
const element = byKey.get(s.key);
|
|
50
|
+
return element ? [{ element, start: s.newStart }] : [];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True when every shift names a real clip that may be time-moved. */
|
|
39
55
|
export function canShiftTrackGapClips(
|
|
40
56
|
laneElements: readonly TimelineElement[],
|
|
41
57
|
shifts: readonly TrackGapShift[],
|
|
42
58
|
): boolean {
|
|
43
|
-
const
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
const resolved = resolveShiftedElements(laneElements, shifts);
|
|
60
|
+
return (
|
|
61
|
+
resolved.length === shifts.length &&
|
|
62
|
+
resolved.every((r) => getTimelineEditCapabilities(r.element).canMove)
|
|
63
|
+
);
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
function buildShiftEdits(
|
|
@@ -52,11 +68,10 @@ function buildShiftEdits(
|
|
|
52
68
|
shifts: readonly TrackGapShift[],
|
|
53
69
|
): TimelineMoveEdit[] | null {
|
|
54
70
|
if (shifts.length === 0 || !canShiftTrackGapClips(laneElements, shifts)) return null;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
71
|
+
return resolveShiftedElements(laneElements, shifts).map(({ element, start }) => ({
|
|
72
|
+
element,
|
|
73
|
+
updates: { start, track: element.track },
|
|
74
|
+
}));
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
function commitShifts(
|
|
@@ -101,3 +116,33 @@ export function commitCloseAllTrackGaps(
|
|
|
101
116
|
deps,
|
|
102
117
|
);
|
|
103
118
|
}
|
|
119
|
+
|
|
120
|
+
/** What a ripple-edit delete must additionally shift on the main track. Pure
|
|
121
|
+
* — no IO; the caller persists the shifts itself. Null means nothing to
|
|
122
|
+
* ripple: off, nothing deleted was on the main track, already gapless, or a
|
|
123
|
+
* shifting clip is locked (same whole-action refusal as the gap-close menu). */
|
|
124
|
+
export function resolveMainTrackDeleteRippleShifts(
|
|
125
|
+
survivingElements: readonly TimelineElement[],
|
|
126
|
+
deletedElements: readonly TimelineElement[],
|
|
127
|
+
rippleEnabled: boolean,
|
|
128
|
+
): TrackGapShift[] | null {
|
|
129
|
+
const deletedMainTrack = deletedElements.filter(isMainTrackElement);
|
|
130
|
+
if (!rippleEnabled || deletedMainTrack.length === 0) return null;
|
|
131
|
+
const survivors = survivingElements.filter(isMainTrackElement);
|
|
132
|
+
// Shift each survivor left by the duration of the deleted clips before it —
|
|
133
|
+
// the removed span itself, not a gap re-resolved on the survivors, which
|
|
134
|
+
// can't tell an untouched adjacent gap from the one just opened.
|
|
135
|
+
const shifts: TrackGapShift[] = [];
|
|
136
|
+
for (const survivor of survivors) {
|
|
137
|
+
const width = deletedMainTrack
|
|
138
|
+
.filter((d) => d.start < survivor.start)
|
|
139
|
+
.reduce((sum, d) => sum + d.duration, 0);
|
|
140
|
+
if (width <= 0) continue;
|
|
141
|
+
const newStart = round3(Math.max(laneGapFloor(survivors), survivor.start - width));
|
|
142
|
+
if (Math.abs(newStart - survivor.start) > TRACK_GAP_EPSILON_S) {
|
|
143
|
+
shifts.push({ key: keyOf(survivor), newStart });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (shifts.length === 0 || !canShiftTrackGapClips(survivors, shifts)) return null;
|
|
147
|
+
return shifts;
|
|
148
|
+
}
|
|
@@ -13,7 +13,7 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
13
13
|
* - Computed starts are rounded to millisecond precision, matching the drag
|
|
14
14
|
* commit's `round3`.
|
|
15
15
|
*/
|
|
16
|
-
const TRACK_GAP_EPSILON_S = 1e-3;
|
|
16
|
+
export const TRACK_GAP_EPSILON_S = 1e-3;
|
|
17
17
|
|
|
18
18
|
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
19
19
|
|
|
@@ -28,7 +28,7 @@ const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
|
28
28
|
export function laneGapFloor(elements: readonly TimelineElement[]): number {
|
|
29
29
|
return Math.max(0, ...elements.map((e) => e.expandedParentStart ?? 0));
|
|
30
30
|
}
|
|
31
|
-
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
31
|
+
export const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
32
32
|
const endOf = (e: TimelineElement) => e.start + e.duration;
|
|
33
33
|
|
|
34
34
|
/** Lane clips sorted by start (key as a deterministic tie-break). */
|
|
@@ -568,8 +568,6 @@ export function resolveTimelineAssetDrop(
|
|
|
568
568
|
scrollTop: number;
|
|
569
569
|
contentOrigin: number;
|
|
570
570
|
pixelsPerSecond: number;
|
|
571
|
-
duration: number;
|
|
572
|
-
clampStartToDuration?: boolean;
|
|
573
571
|
rowHeights?: readonly number[];
|
|
574
572
|
trackOrder: number[];
|
|
575
573
|
},
|
|
@@ -584,10 +582,7 @@ export function resolveTimelineAssetDrop(
|
|
|
584
582
|
});
|
|
585
583
|
const contentY = clientY - input.rectTop + input.scrollTop;
|
|
586
584
|
const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
|
|
587
|
-
const start = Math.max(
|
|
588
|
-
0,
|
|
589
|
-
input.clampStartToDuration === false ? pointerStart : Math.min(input.duration, pointerStart),
|
|
590
|
-
);
|
|
585
|
+
const start = Math.max(0, pointerStart);
|
|
591
586
|
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
|
|
592
587
|
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
|
|
593
588
|
// past the last lane (getDefaultDroppedTrack then appends a new track).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
-
import { classifyZone, normalizeToZones } from "./timelineZones";
|
|
3
|
+
import { classifyZone, isMainTrackElement, normalizeToZones } from "./timelineZones";
|
|
4
4
|
|
|
5
5
|
function el(id: string, tag: string, track: number, duration = 2): TimelineElement {
|
|
6
6
|
return { id, tag, start: 0, duration, track };
|
|
@@ -55,6 +55,25 @@ describe("classifyZone", () => {
|
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
describe("isMainTrackElement", () => {
|
|
59
|
+
it("is true for a visual clip on display track 0", () => {
|
|
60
|
+
expect(isMainTrackElement(el("v", "video", 0))).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("is false for a visual clip on any other track", () => {
|
|
64
|
+
expect(isMainTrackElement(el("v", "video", 1))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("is false for an audio clip even on track 0 (audio-only project has no main track)", () => {
|
|
68
|
+
expect(isMainTrackElement(el("m", "audio", 0))).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("is false for an inline-expanded sub-composition child on track 0", () => {
|
|
72
|
+
const child: TimelineElement = { ...el("c", "video", 0), expandedParentStart: 4 };
|
|
73
|
+
expect(isMainTrackElement(child)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
58
77
|
describe("normalizeToZones — CapCut-stable lanes follow the track-index (never z)", () => {
|
|
59
78
|
it("orders visual lanes by authored track-index (ascending), audio at the bottom", () => {
|
|
60
79
|
// img (track 0), vid (track 2), mus (audio, track 5). Lanes follow the track
|
|
@@ -2,9 +2,9 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
2
2
|
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Free-form vertical zones, top → bottom: visual, audio.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Free-form vertical zones, top → bottom: visual, audio. Canvas layering is
|
|
6
|
+
* CSS z-index (the renderer ignores track index), so a track's only structural
|
|
7
|
+
* job is to keep visual clips grouped above audio clips.
|
|
8
8
|
*/
|
|
9
9
|
export type TrackZone = "visual" | "audio";
|
|
10
10
|
|
|
@@ -14,6 +14,13 @@ export function classifyZone(el: TimelineElement): TrackZone {
|
|
|
14
14
|
return isAudioTimelineElement(el) ? "audio" : "visual";
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** The "main track" is a convention, not a schema field: the first
|
|
18
|
+
* visual-zone display lane, matched only when it actually holds a visual
|
|
19
|
+
* clip and isn't an inline-expanded sub-composition child. */
|
|
20
|
+
export function isMainTrackElement(el: TimelineElement): boolean {
|
|
21
|
+
return el.track === 0 && classifyZone(el) === "visual" && el.expandedParentStart == null;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
18
25
|
|
|
19
26
|
const EPS = 1e-6;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
15
15
|
import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
|
|
16
|
-
import { curveForDrag, formatValue, GRAB_PX,
|
|
16
|
+
import { curveForDrag, formatValue, GRAB_PX, mergeInsertPoint } from "./automationLaneGeometry";
|
|
17
17
|
import { pointInSelection } from "./automationLaneSelection";
|
|
18
18
|
import { capturePointer } from "./automationLanePointer";
|
|
19
19
|
import { useAutomationEdgeStretch } from "./useAutomationEdgeStretch";
|
|
@@ -548,15 +548,7 @@ export function useAutomationLaneGestures({
|
|
|
548
548
|
return;
|
|
549
549
|
}
|
|
550
550
|
const { t, v } = pointAt(e.clientX, e.clientY);
|
|
551
|
-
|
|
552
|
-
// A lane's first point alone would be a constant, which is not what
|
|
553
|
-
// clicking an empty lane means: seed the far end at the same value so the
|
|
554
|
-
// envelope has somewhere to go.
|
|
555
|
-
const seeded = lane.points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
|
|
556
|
-
commitPoints(
|
|
557
|
-
[...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t),
|
|
558
|
-
true,
|
|
559
|
-
);
|
|
551
|
+
commitPoints(mergeInsertPoint(lane.points, t, v), true);
|
|
560
552
|
},
|
|
561
553
|
[lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex],
|
|
562
554
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMemo, useRef } from "react";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import { animationLaneGroups } from "./TimelinePropertyLanes";
|
|
4
|
-
import {
|
|
4
|
+
import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
|
|
5
5
|
import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneData";
|
|
6
6
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
7
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
@@ -44,14 +44,15 @@ export function trackShowsBeatStrip(
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* Automation lanes on one clip
|
|
47
|
+
* Automation lanes on one clip.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* A clip can be worth expanding without carrying a single tween, so this counts
|
|
50
|
+
* toward whether a track has anything to disclose. Gated on
|
|
51
|
+
* `isAudioOrVideoTimelineElement`, matching `groupAutomationLanes`. A function
|
|
52
|
+
* rather than a map so every caller reads the same cached parse and none can drift.
|
|
52
53
|
*/
|
|
53
54
|
function automationLaneCountOf(element: TimelineElement): number {
|
|
54
|
-
return
|
|
55
|
+
return isAudioOrVideoTimelineElement(element) ? elementAutomationLanes(element).length : 0;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
+
// fallow-ignore-file code-duplication
|
|
2
3
|
|
|
3
4
|
import React, { act, useEffect } from "react";
|
|
4
5
|
import { createRoot } from "react-dom/client";
|
|
@@ -171,6 +172,39 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => {
|
|
|
171
172
|
|
|
172
173
|
expect(spies.play).toHaveBeenCalledTimes(1);
|
|
173
174
|
});
|
|
175
|
+
|
|
176
|
+
it("'A' does not seek to the in-point while the razor tool is armed, so it's free for the razor's own return-to-select binding", () => {
|
|
177
|
+
const { dispatch, spies } = setupHook();
|
|
178
|
+
usePlayerStore.setState({ inPoint: 1.5, activeTool: "razor" });
|
|
179
|
+
|
|
180
|
+
act(() => {
|
|
181
|
+
dispatch(keydown({ code: "KeyA", key: "a" }));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(spies.seek).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("'A' still seeks to the in-point when the razor tool isn't armed", () => {
|
|
188
|
+
const { dispatch, spies } = setupHook();
|
|
189
|
+
usePlayerStore.setState({ inPoint: 1.5, activeTool: "select" });
|
|
190
|
+
|
|
191
|
+
act(() => {
|
|
192
|
+
dispatch(keydown({ code: "KeyA", key: "a" }));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("Shift+A still seeks to the in-point while the razor is armed, since only plain A exits the razor", () => {
|
|
199
|
+
const { dispatch, spies } = setupHook();
|
|
200
|
+
usePlayerStore.setState({ inPoint: 1.5, activeTool: "razor" });
|
|
201
|
+
|
|
202
|
+
act(() => {
|
|
203
|
+
dispatch(keydown({ code: "KeyA", key: "a", shiftKey: true }));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
|
|
207
|
+
});
|
|
174
208
|
});
|
|
175
209
|
|
|
176
210
|
describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
|