@hyperframes/studio 0.7.56 → 0.7.58
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-CtTDO63S.js +459 -0
- package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +41 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7436 -5423
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
- package/src/components/editor/CanvasContextMenu.tsx +100 -20
- package/src/components/editor/DomEditOverlay.tsx +34 -62
- package/src/components/editor/LayersPanel.tsx +111 -91
- package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +61 -16
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +29 -2
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +183 -1
- package/src/hooks/timelineEditingHelpers.ts +63 -259
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +7 -4
- package/src/hooks/timelineTimingSync.test.ts +820 -0
- package/src/hooks/timelineTimingSync.ts +597 -0
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +392 -23
- package/src/hooks/useDomEditCommits.ts +180 -53
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
- package/src/hooks/useElementLifecycleOps.ts +175 -69
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +591 -277
- package/src/hooks/useTimelineEditing.ts +202 -282
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +187 -134
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +105 -18
- package/src/player/components/Timeline.tsx +69 -98
- package/src/player/components/TimelineCanvas.tsx +47 -7
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +70 -47
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +370 -11
- package/src/player/components/timelineClipDragCommit.ts +267 -68
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +119 -30
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +102 -1
- package/src/player/components/timelineStackingSync.ts +45 -5
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
- package/src/player/components/useTimelineStackingSync.ts +17 -4
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/lib/timelineElementHelpers.ts +54 -11
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +49 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -3,8 +3,8 @@ import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
|
|
6
|
-
* — layering is CSS z-index (the renderer ignores track index), so the
|
|
7
|
-
* only job is to keep visual clips grouped above audio clips.
|
|
6
|
+
* — canvas layering is CSS z-index (the renderer ignores track index), so the
|
|
7
|
+
* timeline's only job is to keep visual clips grouped above audio clips.
|
|
8
8
|
*/
|
|
9
9
|
export type TrackZone = "visual" | "audio";
|
|
10
10
|
|
|
@@ -16,9 +16,6 @@ export function classifyZone(el: TimelineElement): TrackZone {
|
|
|
16
16
|
|
|
17
17
|
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
18
18
|
|
|
19
|
-
/** Stacking order for a clip: missing / "auto" z is treated as 0. */
|
|
20
|
-
const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0);
|
|
21
|
-
|
|
22
19
|
const EPS = 1e-6;
|
|
23
20
|
|
|
24
21
|
/** Two clips overlap when their half-open [start, end) intervals intersect. */
|
|
@@ -26,173 +23,123 @@ function overlaps(a: TimelineElement, b: TimelineElement): boolean {
|
|
|
26
23
|
return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** One display lane: the clips packed onto it, in placement order. */
|
|
37
|
-
interface Lane {
|
|
38
|
-
occupants: IndexedClip[];
|
|
39
|
-
/** The single authored track all occupants share, or null once mixed (never
|
|
40
|
-
* happens — we only ever add same-track clips to an existing lane). */
|
|
41
|
-
track: number;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Lowest lane index a clip may occupy: strictly above every already-placed lane
|
|
46
|
-
* holding a clip it overlaps in time (all of which out-stack it by the z-desc
|
|
47
|
-
* placement order).
|
|
48
|
-
*/
|
|
49
|
-
function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number {
|
|
50
|
-
let minLane = 0;
|
|
51
|
-
for (let i = 0; i < lanes.length; i++) {
|
|
52
|
-
if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1;
|
|
53
|
-
}
|
|
54
|
-
return minLane;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* First lane at index ≥ minLane that holds solely this clip's authored track and
|
|
59
|
-
* nothing overlapping (so sequential same-track clips share a lane); -1 when none
|
|
60
|
-
* qualifies and a fresh lane must open.
|
|
61
|
-
*/
|
|
62
|
-
function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number {
|
|
63
|
-
for (let i = minLane; i < lanes.length; i++) {
|
|
64
|
-
const lane = lanes[i];
|
|
65
|
-
if (lane.track !== item.el.track) continue;
|
|
66
|
-
if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue;
|
|
67
|
-
return i;
|
|
68
|
-
}
|
|
69
|
-
return -1;
|
|
26
|
+
/** Deterministic order on the stable clip id (never the mutated lane/track). */
|
|
27
|
+
function byStableId(a: TimelineElement, b: TimelineElement): number {
|
|
28
|
+
const ka = keyOf(a);
|
|
29
|
+
const kb = keyOf(b);
|
|
30
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
70
31
|
}
|
|
71
32
|
|
|
72
33
|
/**
|
|
73
|
-
* Pack
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* whole-track mapping can fix that; the mapping must be per-clip.
|
|
34
|
+
* Pack ONE authored track's clips onto sub-lanes so no two time-overlapping clips
|
|
35
|
+
* share a lane. Clips are ordered by their STABLE id (a function of the input, not
|
|
36
|
+
* of the lane being computed — the historical oscillation bug tie-broke on the
|
|
37
|
+
* mutated track) and placed first-fit, so sequential (non-overlapping) clips
|
|
38
|
+
* collapse onto a single lane and only genuine time overlaps spill onto adjacent
|
|
39
|
+
* sub-lanes. Writes each clip's absolute display lane into `laneOf`; returns the
|
|
40
|
+
* number of lanes used (≥ 1 when non-empty).
|
|
81
41
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* must place first / upper); final tie → stable key. NEVER tie-break on the
|
|
86
|
-
* mutated lane/track index (historic oscillation bug — the tie-break must be
|
|
87
|
-
* a stable function of the input, not of the output being computed).
|
|
88
|
-
* 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it
|
|
89
|
-
* OVERLAPS IN TIME). By z-desc placement every already-placed overlapping
|
|
90
|
-
* clip out-stacks this one (higher z, or equal z but later in DOM), so this
|
|
91
|
-
* guarantees lane order == stacking order for every overlapping pair.
|
|
92
|
-
* 3. To preserve the "distinct authored tracks stay distinct / sequential
|
|
93
|
-
* same-track clips share a lane" feel, reuse an existing lane at index ≥ that
|
|
94
|
-
* minimum ONLY when the lane's occupants are all from the SAME authored track
|
|
95
|
-
* AND none overlaps this clip in time; otherwise open a fresh lane.
|
|
42
|
+
* The editor enforces no per-track time overlap, so the spill only fires on legacy
|
|
43
|
+
* files. It is DISPLAY-ONLY — a drag commit persists just the dragged clip, never
|
|
44
|
+
* this re-lane — so it never rewrites the source.
|
|
96
45
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
46
|
+
* Spill sub-lanes ARE legal drop targets (Timeline's trackOrder lists every
|
|
47
|
+
* display lane). Because every occupant of a sub-lane shares the base lane's
|
|
48
|
+
* authored track by construction, dropping a clip onto one persists that shared
|
|
49
|
+
* authored track — a legitimate same-track join. On the next normalize the
|
|
50
|
+
* joined track re-packs (stable-id first-fit), so the clip may display on a
|
|
51
|
+
* DIFFERENT sub-lane than it was dropped on; the packing is deterministic, and
|
|
52
|
+
* the persisted source value is correct either way.
|
|
99
53
|
*/
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
let
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
lanes.push(
|
|
54
|
+
function packTrackLanes(
|
|
55
|
+
clips: TimelineElement[],
|
|
56
|
+
base: number,
|
|
57
|
+
laneOf: Map<string, number>,
|
|
58
|
+
): number {
|
|
59
|
+
const ordered = [...clips].sort(byStableId);
|
|
60
|
+
const lanes: TimelineElement[][] = [];
|
|
61
|
+
for (const el of ordered) {
|
|
62
|
+
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el)));
|
|
63
|
+
if (sub === -1) {
|
|
64
|
+
sub = lanes.length;
|
|
65
|
+
lanes.push([]);
|
|
112
66
|
}
|
|
113
|
-
lanes[
|
|
114
|
-
laneOf.set(keyOf(
|
|
67
|
+
lanes[sub].push(el);
|
|
68
|
+
laneOf.set(keyOf(el), base + sub);
|
|
115
69
|
}
|
|
116
|
-
return lanes.length;
|
|
70
|
+
return Math.max(1, lanes.length);
|
|
117
71
|
}
|
|
118
72
|
|
|
119
73
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* the number of lanes used
|
|
74
|
+
* Pack a whole zone's clips onto contiguous display lanes, CapCut-stable: lanes
|
|
75
|
+
* follow the authored `data-track-index` (ASCENDING; ties by stable id) — NEVER a
|
|
76
|
+
* z-rank. Each distinct authored track owns its own lane (in ascending order);
|
|
77
|
+
* sequential same-track clips share it; time-overlapping same-track clips spill to
|
|
78
|
+
* adjacent sub-lanes (packTrackLanes). Returns the number of lanes used.
|
|
79
|
+
*
|
|
80
|
+
* This REPLACES the old global-z-rank interval pack. That pack ordered visual
|
|
81
|
+
* lanes by z-index and interval-packed overlaps, so editing one clip's z (or the
|
|
82
|
+
* whole-set re-pack a lane drag triggered) silently re-laned OTHER clips. The
|
|
83
|
+
* product decision is the opposite: a clip's lane is its track, period — z is
|
|
84
|
+
* canvas paint order only, and lane assignment must ignore it.
|
|
125
85
|
*/
|
|
126
|
-
function
|
|
127
|
-
clips:
|
|
86
|
+
function packZoneLanes(
|
|
87
|
+
clips: TimelineElement[],
|
|
128
88
|
base: number,
|
|
129
89
|
laneOf: Map<string, number>,
|
|
130
90
|
): number {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el)));
|
|
137
|
-
if (sub === -1) {
|
|
138
|
-
sub = lanes.length;
|
|
139
|
-
lanes.push([]);
|
|
140
|
-
}
|
|
141
|
-
lanes[sub].push(item);
|
|
142
|
-
laneOf.set(keyOf(item.el), base + sub);
|
|
91
|
+
const byTrack = new Map<number, TimelineElement[]>();
|
|
92
|
+
for (const el of clips) {
|
|
93
|
+
const list = byTrack.get(el.track);
|
|
94
|
+
if (list) list.push(el);
|
|
95
|
+
else byTrack.set(el.track, [el]);
|
|
143
96
|
}
|
|
144
|
-
|
|
97
|
+
let used = 0;
|
|
98
|
+
for (const track of [...byTrack.keys()].sort((a, b) => a - b)) {
|
|
99
|
+
used += packTrackLanes(byTrack.get(track)!, base + used, laneOf);
|
|
100
|
+
}
|
|
101
|
+
return used;
|
|
145
102
|
}
|
|
146
103
|
|
|
147
104
|
/**
|
|
148
105
|
* Assign display lanes for the timeline: visual lanes on top, audio lanes below.
|
|
149
106
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* The AUDIO zone keeps the original behavior — authored-track order, per-track
|
|
158
|
-
* interval packing — because audio has no z / stacking semantics.
|
|
107
|
+
* Both zones are packed the SAME way — by authored track-index, ascending (see
|
|
108
|
+
* packZoneLanes) — so the timeline's vertical order follows each clip's track and
|
|
109
|
+
* nothing else. z-index does not participate in lane assignment (it is canvas
|
|
110
|
+
* paint order only; the lane ↔ z stacking sync in timelineStackingSync runs the
|
|
111
|
+
* other direction, only on a deliberate vertical edit). Time-overlapping same-track
|
|
112
|
+
* clips still split onto separate sub-lanes (legacy files only — the editor forbids
|
|
113
|
+
* per-track overlap), and that split is display-only, never persisted.
|
|
159
114
|
*
|
|
160
115
|
* Pure — returns a new array; unchanged clips keep their identity. Display-only
|
|
161
116
|
* (runs on discovery); it does not rewrite the source. Idempotent (running it on
|
|
162
|
-
* its own output is a fixed point)
|
|
117
|
+
* its own output is a fixed point): the lanes it emits are contiguous integers in
|
|
118
|
+
* ascending track order, and re-running groups by those same integers unchanged.
|
|
163
119
|
*/
|
|
164
120
|
export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
|
|
165
121
|
if (elements.length === 0) return elements;
|
|
166
122
|
|
|
167
123
|
const laneOf = new Map<string, number>();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
(classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex });
|
|
174
|
-
});
|
|
124
|
+
const visual: TimelineElement[] = [];
|
|
125
|
+
const audio: TimelineElement[] = [];
|
|
126
|
+
for (const el of elements) {
|
|
127
|
+
(classifyZone(el) === "audio" ? audio : visual).push(el);
|
|
128
|
+
}
|
|
175
129
|
|
|
130
|
+
let nextLane = 0;
|
|
176
131
|
nextLane += packZoneLanes(visual, nextLane, laneOf);
|
|
177
|
-
|
|
178
|
-
// Audio: preserve legacy behavior — group by authored track (ascending), pack
|
|
179
|
-
// each track's overlapping clips onto sub-lanes.
|
|
180
|
-
const audioByTrack = new Map<number, IndexedClip[]>();
|
|
181
|
-
for (const item of audio) {
|
|
182
|
-
const list = audioByTrack.get(item.el.track);
|
|
183
|
-
if (list) list.push(item);
|
|
184
|
-
else audioByTrack.set(item.el.track, [item]);
|
|
185
|
-
}
|
|
186
|
-
for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) {
|
|
187
|
-
nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf);
|
|
188
|
-
}
|
|
132
|
+
packZoneLanes(audio, nextLane, laneOf);
|
|
189
133
|
|
|
190
134
|
let changed = false;
|
|
191
135
|
const remapped = elements.map((el) => {
|
|
192
136
|
const lane = laneOf.get(keyOf(el));
|
|
193
137
|
if (lane == null || lane === el.track) return el;
|
|
194
138
|
changed = true;
|
|
195
|
-
|
|
139
|
+
// Record the source-file track the first time a clip is remapped so lane
|
|
140
|
+
// edits can persist in AUTHORED space (see TimelineElement.authoredTrack).
|
|
141
|
+
// Re-normalizing already-remapped elements must keep the original value.
|
|
142
|
+
return { ...el, track: lane, authoredTrack: el.authoredTrack ?? el.track };
|
|
196
143
|
});
|
|
197
144
|
return changed ? remapped : elements;
|
|
198
145
|
}
|
|
@@ -45,11 +45,11 @@ describe("getTimelinePixelsPerSecond", () => {
|
|
|
45
45
|
|
|
46
46
|
describe("getNextTimelineZoomPercent", () => {
|
|
47
47
|
it("zooms out from fit relative to 100 percent", () => {
|
|
48
|
-
expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(
|
|
48
|
+
expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(50);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
it("zooms in from fit relative to 100 percent", () => {
|
|
52
|
-
expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(
|
|
52
|
+
expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(200);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
it("clamps the lower bound", () => {
|
|
@@ -2,9 +2,13 @@ import type { ZoomMode } from "../store/playerStore";
|
|
|
2
2
|
|
|
3
3
|
export const MIN_TIMELINE_ZOOM_PERCENT = 10;
|
|
4
4
|
export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// CapCut-strength steps: one button press / pinch gesture moves the zoom
|
|
6
|
+
// meaningfully (user feedback, twice-doubled: 1.25×/0.8× + 0.0035 felt like
|
|
7
|
+
// "zooming several times to get anywhere", then 1.5× + 0.007 still too soft).
|
|
8
|
+
// Kept reciprocal (2 × 0.5 = 1) so in+out round-trips.
|
|
9
|
+
const ZOOM_OUT_FACTOR = 0.5;
|
|
10
|
+
const ZOOM_IN_FACTOR = 2;
|
|
11
|
+
const PINCH_ZOOM_SENSITIVITY = 0.014;
|
|
8
12
|
|
|
9
13
|
export function clampTimelineZoomPercent(percent: number): number {
|
|
10
14
|
if (!Number.isFinite(percent)) return 100;
|
|
@@ -76,7 +76,7 @@ interface UseTimelineClipDragInput {
|
|
|
76
76
|
* commitDraggedClipMove. Both optional → absent = no-op (backward compatible).
|
|
77
77
|
*/
|
|
78
78
|
readZIndex?: (element: TimelineElement) => number;
|
|
79
|
-
onStackingPatches?: (patches: StackingPatch[]) => void;
|
|
79
|
+
onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export function useTimelineClipDrag({
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { buildTimelineGapStrips } from "./useTimelineGapHighlights";
|
|
4
|
+
|
|
5
|
+
function el(id: string, track: number, start: number, duration: number): TimelineElement {
|
|
6
|
+
return { id, key: id, tag: "video", start, duration, track, domId: id };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const laneA = [el("a1", 0, 1, 2), el("a2", 0, 5, 2)]; // gaps: [0,1), [3,5)
|
|
10
|
+
const laneB = [el("b1", 1, 0, 2), el("b2", 1, 2, 3)]; // contiguous
|
|
11
|
+
const tracks: [number, TimelineElement[]][] = [
|
|
12
|
+
[0, laneA],
|
|
13
|
+
[1, laneB],
|
|
14
|
+
];
|
|
15
|
+
const expandedElements = [...laneA, ...laneB];
|
|
16
|
+
|
|
17
|
+
const base = {
|
|
18
|
+
gapHighlight: null,
|
|
19
|
+
tracks,
|
|
20
|
+
selectedElementId: null,
|
|
21
|
+
selectedElementIds: new Set<string>(),
|
|
22
|
+
expandedElements,
|
|
23
|
+
dragActive: false,
|
|
24
|
+
displayDuration: 60,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("buildTimelineGapStrips", () => {
|
|
28
|
+
it("emits a loud hover strip set from the gap-menu highlight", () => {
|
|
29
|
+
const strips = buildTimelineGapStrips({
|
|
30
|
+
...base,
|
|
31
|
+
gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
|
|
32
|
+
});
|
|
33
|
+
expect(strips).toEqual([{ track: 0, intervals: [{ start: 3, end: 5 }], kind: "hover" }]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("click-selection lights the WHOLE lane minus clips, trailing space included", () => {
|
|
37
|
+
const strips = buildTimelineGapStrips({ ...base, selectedElementId: "a1" });
|
|
38
|
+
expect(strips).toEqual([
|
|
39
|
+
{
|
|
40
|
+
track: 0,
|
|
41
|
+
intervals: [
|
|
42
|
+
{ start: 0, end: 1 },
|
|
43
|
+
{ start: 3, end: 5 },
|
|
44
|
+
{ start: 7, end: 60 },
|
|
45
|
+
],
|
|
46
|
+
kind: "selected",
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("a contiguous lane still lights its trailing open space", () => {
|
|
52
|
+
expect(buildTimelineGapStrips({ ...base, selectedElementId: "b1" })).toEqual([
|
|
53
|
+
{ track: 1, intervals: [{ start: 5, end: 60 }], kind: "selected" },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("a lane filling the whole rendered extent emits nothing", () => {
|
|
58
|
+
const laneFull = [el("f1", 5, 0, 60)];
|
|
59
|
+
const strips = buildTimelineGapStrips({
|
|
60
|
+
...base,
|
|
61
|
+
tracks: [...tracks, [5, laneFull]],
|
|
62
|
+
expandedElements: [...expandedElements, ...laneFull],
|
|
63
|
+
selectedElementId: "f1",
|
|
64
|
+
});
|
|
65
|
+
expect(strips).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("a one-member selectedElementIds mirror of the click still counts as single", () => {
|
|
69
|
+
// The store mirrors a plain click into selectedElementIds = {clicked}.
|
|
70
|
+
const strips = buildTimelineGapStrips({
|
|
71
|
+
...base,
|
|
72
|
+
selectedElementId: "a1",
|
|
73
|
+
selectedElementIds: new Set(["a1"]),
|
|
74
|
+
});
|
|
75
|
+
expect(strips).toHaveLength(1);
|
|
76
|
+
expect(strips[0].kind).toBe("selected");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("marquee multi-selection never emits the selected hint", () => {
|
|
80
|
+
const strips = buildTimelineGapStrips({
|
|
81
|
+
...base,
|
|
82
|
+
selectedElementId: "a1",
|
|
83
|
+
selectedElementIds: new Set(["a1", "b1"]),
|
|
84
|
+
});
|
|
85
|
+
expect(strips).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("hover wins on its own lane — no doubled strips for the same track", () => {
|
|
89
|
+
const strips = buildTimelineGapStrips({
|
|
90
|
+
...base,
|
|
91
|
+
gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
|
|
92
|
+
selectedElementId: "a1",
|
|
93
|
+
});
|
|
94
|
+
expect(strips).toHaveLength(1);
|
|
95
|
+
expect(strips[0].kind).toBe("hover");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("hover and selection on DIFFERENT lanes coexist", () => {
|
|
99
|
+
const laneC = [el("c1", 2, 4, 2)];
|
|
100
|
+
const strips = buildTimelineGapStrips({
|
|
101
|
+
...base,
|
|
102
|
+
tracks: [...tracks, [2, laneC]],
|
|
103
|
+
expandedElements: [...expandedElements, ...laneC],
|
|
104
|
+
gapHighlight: { track: 2, intervals: [{ start: 0, end: 4 }] },
|
|
105
|
+
selectedElementId: "a1",
|
|
106
|
+
});
|
|
107
|
+
expect(strips.map((s) => s.kind)).toEqual(["hover", "selected"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("a live drag suppresses every strip", () => {
|
|
111
|
+
const strips = buildTimelineGapStrips({
|
|
112
|
+
...base,
|
|
113
|
+
dragActive: true,
|
|
114
|
+
gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
|
|
115
|
+
selectedElementId: "a1",
|
|
116
|
+
});
|
|
117
|
+
expect(strips).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { laneGapFloor, resolveLaneEmptyIntervals, type TrackGapInterval } from "./timelineGaps";
|
|
4
|
+
import type { TrackGapHighlight } from "./useTrackGapMenu";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One lane's gap strips for the TimelineCanvas overlay.
|
|
8
|
+
*
|
|
9
|
+
* kind "hover" — the gap(s) a hovered "Close gap" / "Close all gaps" menu
|
|
10
|
+
* row would collapse: the loud affordance.
|
|
11
|
+
* kind "selected" — every empty interval on a click-selected clip's lane: the
|
|
12
|
+
* quiet always-on hint (single click-selection only — a
|
|
13
|
+
* marquee multi-select spans lanes and would paint noise).
|
|
14
|
+
*/
|
|
15
|
+
export interface TimelineLaneGapStrips {
|
|
16
|
+
track: number;
|
|
17
|
+
intervals: TrackGapInterval[];
|
|
18
|
+
kind: "hover" | "selected";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Derive the gap strips TimelineCanvas paints. The menu-hover highlight wins
|
|
23
|
+
* on its lane (painting both would just double the same strips); the
|
|
24
|
+
* selected-clip hint renders on the selection's lane otherwise. Suppressed
|
|
25
|
+
* entirely during a live drag — the drop placeholder / insert line own that
|
|
26
|
+
* moment, and the lane set is in flux.
|
|
27
|
+
*/
|
|
28
|
+
interface GapHighlightInput {
|
|
29
|
+
gapHighlight: TrackGapHighlight | null;
|
|
30
|
+
tracks: [number, TimelineElement[]][];
|
|
31
|
+
selectedElementId: string | null;
|
|
32
|
+
selectedElementIds: ReadonlySet<string>;
|
|
33
|
+
expandedElements: TimelineElement[];
|
|
34
|
+
dragActive: boolean;
|
|
35
|
+
/** Rendered timeline extent (seconds) — the selected-lane highlight spans the
|
|
36
|
+
* WHOLE lane minus its clips, trailing open space included. */
|
|
37
|
+
displayDuration: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Single selection only: the store mirrors a plain click into a one-member
|
|
42
|
+
* selectedElementIds set (setSelectedElementId collapses the multi-select),
|
|
43
|
+
* so "single" means empty OR exactly the selected clip itself. A marquee
|
|
44
|
+
* multi-select spans lanes and stays hint-free.
|
|
45
|
+
*/
|
|
46
|
+
function isSingleSelection(selectedElementId: string, ids: ReadonlySet<string>): boolean {
|
|
47
|
+
return ids.size === 0 || (ids.size === 1 && ids.has(selectedElementId));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The subtle strips for a single click-selected clip's lane, or null. */
|
|
51
|
+
function selectedLaneStrips(input: GapHighlightInput): TimelineLaneGapStrips | null {
|
|
52
|
+
const { selectedElementId, selectedElementIds, expandedElements, tracks, gapHighlight } = input;
|
|
53
|
+
if (!selectedElementId || !isSingleSelection(selectedElementId, selectedElementIds)) return null;
|
|
54
|
+
const selected = expandedElements.find((el) => (el.key ?? el.id) === selectedElementId);
|
|
55
|
+
if (!selected || selected.track === gapHighlight?.track) return null;
|
|
56
|
+
const laneElements = tracks.find(([t]) => t === selected.track)?.[1] ?? [];
|
|
57
|
+
const intervals = resolveLaneEmptyIntervals(
|
|
58
|
+
laneElements,
|
|
59
|
+
input.displayDuration,
|
|
60
|
+
undefined,
|
|
61
|
+
laneGapFloor(laneElements),
|
|
62
|
+
);
|
|
63
|
+
return intervals.length > 0 ? { track: selected.track, intervals, kind: "selected" } : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Pure strip derivation — exported for direct unit testing. */
|
|
67
|
+
export function buildTimelineGapStrips(input: GapHighlightInput): TimelineLaneGapStrips[] {
|
|
68
|
+
if (input.dragActive) return [];
|
|
69
|
+
const strips: TimelineLaneGapStrips[] = [];
|
|
70
|
+
if (input.gapHighlight && input.gapHighlight.intervals.length > 0) {
|
|
71
|
+
strips.push({ ...input.gapHighlight, kind: "hover" });
|
|
72
|
+
}
|
|
73
|
+
// Single click-selection → subtle gap hint on that clip's lane.
|
|
74
|
+
const selected = selectedLaneStrips(input);
|
|
75
|
+
if (selected) strips.push(selected);
|
|
76
|
+
return strips;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useTimelineGapHighlights(input: GapHighlightInput): TimelineLaneGapStrips[] {
|
|
80
|
+
const {
|
|
81
|
+
gapHighlight,
|
|
82
|
+
tracks,
|
|
83
|
+
selectedElementId,
|
|
84
|
+
selectedElementIds,
|
|
85
|
+
expandedElements,
|
|
86
|
+
dragActive,
|
|
87
|
+
displayDuration,
|
|
88
|
+
} = input;
|
|
89
|
+
return useMemo(
|
|
90
|
+
() =>
|
|
91
|
+
buildTimelineGapStrips({
|
|
92
|
+
gapHighlight,
|
|
93
|
+
tracks,
|
|
94
|
+
selectedElementId,
|
|
95
|
+
selectedElementIds,
|
|
96
|
+
expandedElements,
|
|
97
|
+
dragActive,
|
|
98
|
+
displayDuration,
|
|
99
|
+
}),
|
|
100
|
+
[
|
|
101
|
+
gapHighlight,
|
|
102
|
+
tracks,
|
|
103
|
+
selectedElementId,
|
|
104
|
+
selectedElementIds,
|
|
105
|
+
expandedElements,
|
|
106
|
+
dragActive,
|
|
107
|
+
displayDuration,
|
|
108
|
+
],
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
4
4
|
import { getPinchTimelineZoomPercent } from "./timelineZoom";
|
|
5
5
|
import {
|
|
6
6
|
GUTTER,
|
|
7
|
+
TRACKS_LEFT_PAD,
|
|
7
8
|
getTimelinePlayheadLeft,
|
|
8
9
|
getTimelineScrollLeftForZoomTransition,
|
|
9
10
|
getTimelineScrollLeftForZoomAnchor,
|
|
@@ -73,7 +74,7 @@ export function useTimelinePlayhead({
|
|
|
73
74
|
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
74
75
|
pointerX: scroll.clientWidth / 2,
|
|
75
76
|
currentScrollLeft: scroll.scrollLeft,
|
|
76
|
-
gutter: GUTTER,
|
|
77
|
+
gutter: GUTTER + TRACKS_LEFT_PAD,
|
|
77
78
|
currentPixelsPerSecond: prevPps,
|
|
78
79
|
nextPixelsPerSecond: pps,
|
|
79
80
|
duration: durationRef.current,
|
|
@@ -129,7 +130,7 @@ export function useTimelinePlayhead({
|
|
|
129
130
|
const el = scrollRef.current;
|
|
130
131
|
if (!el || effectiveDuration <= 0) return;
|
|
131
132
|
const rect = el.getBoundingClientRect();
|
|
132
|
-
const x = clientX - rect.left + el.scrollLeft - GUTTER;
|
|
133
|
+
const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
|
133
134
|
if (x < 0) return;
|
|
134
135
|
const time = Math.max(0, Math.min(effectiveDuration, x / pps));
|
|
135
136
|
liveTime.notify(time);
|
|
@@ -185,7 +186,7 @@ export function useTimelinePlayhead({
|
|
|
185
186
|
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
186
187
|
pointerX: e.clientX - rect.left,
|
|
187
188
|
currentScrollLeft: scroll.scrollLeft,
|
|
188
|
-
gutter: GUTTER,
|
|
189
|
+
gutter: GUTTER + TRACKS_LEFT_PAD,
|
|
189
190
|
currentPixelsPerSecond: ppsRef.current,
|
|
190
191
|
nextPixelsPerSecond: nextPps,
|
|
191
192
|
duration: durationRef.current,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "./timelineEditing";
|
|
8
8
|
import type { TimelineElement } from "../store/playerStore";
|
|
9
9
|
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
10
|
-
import { GUTTER } from "./timelineLayout";
|
|
10
|
+
import { GUTTER, TRACKS_LEFT_PAD } from "./timelineLayout";
|
|
11
11
|
import {
|
|
12
12
|
computeMarqueeSelection,
|
|
13
13
|
getMarqueeRect,
|
|
@@ -228,7 +228,8 @@ export function useTimelineRangeSelection({
|
|
|
228
228
|
setShowPopover(false);
|
|
229
229
|
const rect = scrollRef.current?.getBoundingClientRect();
|
|
230
230
|
if (rect) {
|
|
231
|
-
const x =
|
|
231
|
+
const x =
|
|
232
|
+
e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
|
|
232
233
|
const time = Math.max(0, x / pps);
|
|
233
234
|
rangeAnchorTime.current = time;
|
|
234
235
|
setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
|
|
@@ -285,7 +286,7 @@ export function useTimelineRangeSelection({
|
|
|
285
286
|
const el = scrollRef.current;
|
|
286
287
|
if (el) {
|
|
287
288
|
const rect = el.getBoundingClientRect();
|
|
288
|
-
const x = clientX - rect.left + el.scrollLeft - GUTTER;
|
|
289
|
+
const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
|
289
290
|
if (x >= 0) {
|
|
290
291
|
const dur = el.scrollWidth / pps;
|
|
291
292
|
liveTime.notify(Math.max(0, Math.min(dur, x / pps)));
|
|
@@ -309,7 +310,8 @@ export function useTimelineRangeSelection({
|
|
|
309
310
|
if (isRangeSelecting.current) {
|
|
310
311
|
const rect = scrollRef.current?.getBoundingClientRect();
|
|
311
312
|
if (rect) {
|
|
312
|
-
const x =
|
|
313
|
+
const x =
|
|
314
|
+
e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
|
|
313
315
|
setRangeSelection((prev) =>
|
|
314
316
|
prev
|
|
315
317
|
? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
|