@hyperframes/studio 0.8.47 → 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-B7fsB8C2.js → hyperframes-player-CtVX6jL_.js} +1 -1
- package/dist/assets/{index-ryL7q1QG.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-B50xQfSG.js → index-D_j-0PD3.js} +1 -1
- package/dist/index.d.ts +66 -23
- package/dist/index.html +2 -2
- package/dist/index.js +1015 -621
- 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/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/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/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +16 -21
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/sdkCutover.test.ts +51 -0
- 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/index-DnRfAiK2.css +0 -1
- package/dist/assets/index-j-4hCrZU.js +0 -441
|
@@ -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)", () => {
|
|
@@ -26,6 +26,14 @@ interface UsePlaybackKeyboardParams {
|
|
|
26
26
|
seek: (time: number, options?: { keepPlaying?: boolean }) => void;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// The razor tool owns plain "a" (return to select) while it's armed; this
|
|
30
|
+
// can't rely on defaultPrevented since both listeners register on window and
|
|
31
|
+
// fire in an order this hook doesn't control. Shift+A and Alt+A are left for
|
|
32
|
+
// the in-point seek below, since only plain A means "exit the razor".
|
|
33
|
+
function shouldRazorClaimPlainA(e: KeyboardEvent): boolean {
|
|
34
|
+
return !e.shiftKey && !e.altKey && usePlayerStore.getState().activeTool === "razor";
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
export function usePlaybackKeyboard({
|
|
30
38
|
iframeRef,
|
|
31
39
|
shuttleDirectionRef,
|
|
@@ -79,6 +87,10 @@ export function usePlaybackKeyboard({
|
|
|
79
87
|
}
|
|
80
88
|
}, [play, pause]);
|
|
81
89
|
|
|
90
|
+
// Pre-existing dispatcher, already over the complexity gate before this
|
|
91
|
+
// branch's one-line "a" fix touched it. A real fix is a dispatch-table
|
|
92
|
+
// rewrite, out of scope here; the branch this PR changed is its own function.
|
|
93
|
+
// fallow-ignore-next-line complexity
|
|
82
94
|
const handlePlaybackKeyDown = useCallback(
|
|
83
95
|
(e: KeyboardEvent) => {
|
|
84
96
|
if (e.defaultPrevented) return;
|
|
@@ -161,6 +173,7 @@ export function usePlaybackKeyboard({
|
|
|
161
173
|
return;
|
|
162
174
|
}
|
|
163
175
|
if (key === "a") {
|
|
176
|
+
if (shouldRazorClaimPlainA(e)) return;
|
|
164
177
|
e.preventDefault();
|
|
165
178
|
seek(usePlayerStore.getState().inPoint ?? 0, { keepPlaying: true });
|
|
166
179
|
return;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
+
// fallow-ignore-file code-duplication
|
|
2
3
|
import { describe, expect, it, vi } from "vitest";
|
|
3
4
|
import {
|
|
4
5
|
applyPreviewAudioFlags,
|
|
@@ -54,6 +55,27 @@ describe("buildMissingCompositionElements — hfId (R7)", () => {
|
|
|
54
55
|
expect(entry).toBeDefined();
|
|
55
56
|
expect(entry?.hfId).toBeUndefined();
|
|
56
57
|
});
|
|
58
|
+
|
|
59
|
+
it("carries the resolved track onto authoredTrack, so splitting a recovered composition host can't drift to a new row", () => {
|
|
60
|
+
const doc = makeDoc(`
|
|
61
|
+
<div data-composition-id="root">
|
|
62
|
+
<div
|
|
63
|
+
data-composition-id="scene-c"
|
|
64
|
+
data-composition-src="scenes/c.html"
|
|
65
|
+
data-track-index="2"
|
|
66
|
+
data-start="0"
|
|
67
|
+
data-duration="5"
|
|
68
|
+
></div>
|
|
69
|
+
</div>
|
|
70
|
+
`);
|
|
71
|
+
|
|
72
|
+
const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
|
|
73
|
+
const entry = missing[0];
|
|
74
|
+
|
|
75
|
+
expect(entry).toBeDefined();
|
|
76
|
+
expect(entry?.track).toBe(2);
|
|
77
|
+
expect(entry?.authoredTrack).toBe(2);
|
|
78
|
+
});
|
|
57
79
|
});
|
|
58
80
|
|
|
59
81
|
describe("setPreviewMediaVolume", () => {
|
|
@@ -452,6 +452,7 @@ function buildMissingCompositionEntry(params: {
|
|
|
452
452
|
start: window.start,
|
|
453
453
|
duration: window.duration,
|
|
454
454
|
track: timing.trackIndex,
|
|
455
|
+
authoredTrack: timing.trackIndex,
|
|
455
456
|
domId: optionalNonEmpty(element.id),
|
|
456
457
|
hfId: optionalNonEmpty(element.getAttribute("data-hf-id")),
|
|
457
458
|
selector,
|