@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
3
|
+
import {
|
|
4
|
+
persistMoveEdits,
|
|
5
|
+
type DragCommitDeps,
|
|
6
|
+
type TimelineMoveEdit,
|
|
7
|
+
} from "./timelineClipDragCommit";
|
|
8
|
+
import {
|
|
9
|
+
laneGapFloor,
|
|
10
|
+
resolveAllTrackGaps,
|
|
11
|
+
resolveCloseGapShifts,
|
|
12
|
+
resolveTrackGapAt,
|
|
13
|
+
type TrackGapShift,
|
|
14
|
+
} from "./timelineGaps";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Commit layer for the track-gap context menu ("Close gap" / "Close all gaps").
|
|
18
|
+
*
|
|
19
|
+
* Each action is ONE atomic {@link persistMoveEdits} batch — pure time moves
|
|
20
|
+
* (`updates.track === element.track`, no authored-track rewrite) tagged with a
|
|
21
|
+
* per-gesture-unique coalesce key, so an action is exactly one undo entry and
|
|
22
|
+
* flows through the existing move pipeline (optimistic store apply + rollback,
|
|
23
|
+
* SDK fast path, patchIframeDomTiming preview).
|
|
24
|
+
*
|
|
25
|
+
* Refusal rule: if ANY clip that must shift is unmovable
|
|
26
|
+
* ({@link getTimelineEditCapabilities}.canMove === false), the whole action is
|
|
27
|
+
* refused — never a partial compaction. The menu disables the item via
|
|
28
|
+
* {@link canShiftTrackGapClips}; the commit re-checks as defense in depth.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
32
|
+
|
|
33
|
+
// Per-gesture-unique coalesce key. A monotonic counter — NOT Date.now() /
|
|
34
|
+
// Math.random() (determinism rules) — mirrors laneChangeGestureSeq in
|
|
35
|
+
// timelineClipDragCommit.ts.
|
|
36
|
+
let gapCloseGestureSeq = 0;
|
|
37
|
+
|
|
38
|
+
/** True when every clip named in `shifts` may be time-moved. */
|
|
39
|
+
export function canShiftTrackGapClips(
|
|
40
|
+
laneElements: readonly TimelineElement[],
|
|
41
|
+
shifts: readonly TrackGapShift[],
|
|
42
|
+
): boolean {
|
|
43
|
+
const byKey = new Map(laneElements.map((e) => [keyOf(e), e]));
|
|
44
|
+
return shifts.every((s) => {
|
|
45
|
+
const element = byKey.get(s.key);
|
|
46
|
+
return element != null && getTimelineEditCapabilities(element).canMove;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildShiftEdits(
|
|
51
|
+
laneElements: readonly TimelineElement[],
|
|
52
|
+
shifts: readonly TrackGapShift[],
|
|
53
|
+
): TimelineMoveEdit[] | null {
|
|
54
|
+
if (shifts.length === 0 || !canShiftTrackGapClips(laneElements, shifts)) return null;
|
|
55
|
+
const byKey = new Map(laneElements.map((e) => [keyOf(e), e]));
|
|
56
|
+
return shifts.map((s) => {
|
|
57
|
+
const element = byKey.get(s.key)!;
|
|
58
|
+
return { element, updates: { start: s.newStart, track: element.track } };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function commitShifts(
|
|
63
|
+
laneElements: readonly TimelineElement[],
|
|
64
|
+
shifts: readonly TrackGapShift[],
|
|
65
|
+
deps: DragCommitDeps,
|
|
66
|
+
): boolean {
|
|
67
|
+
const edits = buildShiftEdits(laneElements, shifts);
|
|
68
|
+
if (!edits) return false;
|
|
69
|
+
void persistMoveEdits(edits, deps, `track-gap-close:${gapCloseGestureSeq++}`);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Close the ONE gap under `time` on the lane: the next clip and every clip
|
|
75
|
+
* after it on that lane shift left by the gap's width. Returns false (and
|
|
76
|
+
* writes nothing) when there is no gap at the point or a shifting clip is
|
|
77
|
+
* unmovable.
|
|
78
|
+
*/
|
|
79
|
+
export function commitCloseTrackGap(
|
|
80
|
+
laneElements: readonly TimelineElement[],
|
|
81
|
+
time: number,
|
|
82
|
+
deps: DragCommitDeps,
|
|
83
|
+
): boolean {
|
|
84
|
+
const gap = resolveTrackGapAt(laneElements, time, undefined, laneGapFloor(laneElements));
|
|
85
|
+
if (!gap) return false;
|
|
86
|
+
return commitShifts(laneElements, resolveCloseGapShifts(laneElements, gap), deps);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compact the whole lane (leading gap included): clips become contiguous from
|
|
91
|
+
* 0, order and durations preserved. Returns false (and writes nothing) when
|
|
92
|
+
* the lane has no gaps or a shifting clip is unmovable.
|
|
93
|
+
*/
|
|
94
|
+
export function commitCloseAllTrackGaps(
|
|
95
|
+
laneElements: readonly TimelineElement[],
|
|
96
|
+
deps: DragCommitDeps,
|
|
97
|
+
): boolean {
|
|
98
|
+
return commitShifts(
|
|
99
|
+
laneElements,
|
|
100
|
+
resolveAllTrackGaps(laneElements, undefined, laneGapFloor(laneElements)),
|
|
101
|
+
deps,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import {
|
|
4
|
+
laneGapFloor,
|
|
5
|
+
resolveAllGapIntervals,
|
|
6
|
+
resolveAllTrackGaps,
|
|
7
|
+
resolveCloseGapShifts,
|
|
8
|
+
resolveTrackGapAt,
|
|
9
|
+
trackHasGaps,
|
|
10
|
+
} from "./timelineGaps";
|
|
11
|
+
|
|
12
|
+
function el(id: string, start: number, duration: number): TimelineElement {
|
|
13
|
+
return { id, tag: "video", start, duration, track: 0, domId: id };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("resolveTrackGapAt", () => {
|
|
17
|
+
it("resolves a middle gap between two clips", () => {
|
|
18
|
+
const els = [el("a", 0, 2), el("b", 5, 3)];
|
|
19
|
+
const gap = resolveTrackGapAt(els, 3);
|
|
20
|
+
expect(gap).toEqual({ gapStart: 2, gapEnd: 5, followingKeys: ["b"] });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("resolves the leading gap before the first clip (gapStart = 0)", () => {
|
|
24
|
+
const els = [el("a", 2, 3), el("b", 6, 1)];
|
|
25
|
+
const gap = resolveTrackGapAt(els, 1);
|
|
26
|
+
expect(gap).toEqual({ gapStart: 0, gapEnd: 2, followingKeys: ["a", "b"] });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("includes EVERY clip at/after the gap in followingKeys", () => {
|
|
30
|
+
const els = [el("a", 0, 1), el("b", 3, 1), el("c", 5, 1), el("d", 8, 1)];
|
|
31
|
+
const gap = resolveTrackGapAt(els, 2);
|
|
32
|
+
expect(gap).toEqual({ gapStart: 1, gapEnd: 3, followingKeys: ["b", "c", "d"] });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns null when there is no clip to the right of the point", () => {
|
|
36
|
+
const els = [el("a", 0, 2)];
|
|
37
|
+
expect(resolveTrackGapAt(els, 5)).toBeNull();
|
|
38
|
+
expect(resolveTrackGapAt([], 1)).toBeNull(); // empty lane
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns null when the point is inside a clip (half-open interval)", () => {
|
|
42
|
+
const els = [el("a", 1, 2), el("b", 5, 1)];
|
|
43
|
+
expect(resolveTrackGapAt(els, 2)).toBeNull(); // strictly inside
|
|
44
|
+
expect(resolveTrackGapAt(els, 1)).toBeNull(); // at clip start (occupied)
|
|
45
|
+
// At clip END (half-open) the point is free — the gap to "b" resolves.
|
|
46
|
+
expect(resolveTrackGapAt(els, 3)).toEqual({ gapStart: 3, gapEnd: 5, followingKeys: ["b"] });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("resolves the leading gap for a single clip", () => {
|
|
50
|
+
const gap = resolveTrackGapAt([el("a", 4, 2)], 1);
|
|
51
|
+
expect(gap).toEqual({ gapStart: 0, gapEnd: 4, followingKeys: ["a"] });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("treats epsilon-adjacent clips as gapless (float drift)", () => {
|
|
55
|
+
// 8.4 + 2.7 = 11.100000000000001 — the classic drift: no point near the
|
|
56
|
+
// seam resolves a gap.
|
|
57
|
+
const drifted = [el("a", 8.4, 2.7), el("b", 11.1, 2)];
|
|
58
|
+
expect(resolveTrackGapAt(drifted, 11.0999)).toBeNull();
|
|
59
|
+
expect(resolveTrackGapAt(drifted, 11.1005)).toBeNull();
|
|
60
|
+
// A sub-epsilon sliver between clips is not a closable gap either.
|
|
61
|
+
const sliver = [el("a", 0, 2.0004), el("b", 2.001, 1)];
|
|
62
|
+
expect(resolveTrackGapAt(sliver, 1.9995)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles overlapping clips sanely (uses the max end left of the point)", () => {
|
|
66
|
+
const els = [el("a", 0, 4), el("b", 1, 2), el("c", 6, 1)];
|
|
67
|
+
const gap = resolveTrackGapAt(els, 5);
|
|
68
|
+
expect(gap).toEqual({ gapStart: 4, gapEnd: 6, followingKeys: ["c"] });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("prefers the key over the id when present", () => {
|
|
72
|
+
const withKey = { ...el("a", 3, 1), key: "a-key" };
|
|
73
|
+
const gap = resolveTrackGapAt([withKey], 1);
|
|
74
|
+
expect(gap?.followingKeys).toEqual(["a-key"]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("resolveCloseGapShifts", () => {
|
|
79
|
+
it("shifts the following clips left by exactly the gap width", () => {
|
|
80
|
+
const els = [el("a", 0, 2), el("b", 5, 3), el("c", 10, 1)];
|
|
81
|
+
const gap = resolveTrackGapAt(els, 3)!;
|
|
82
|
+
expect(resolveCloseGapShifts(els, gap)).toEqual([
|
|
83
|
+
{ key: "b", newStart: 2 },
|
|
84
|
+
{ key: "c", newStart: 7 },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("closing the leading gap lands the first clip at 0", () => {
|
|
89
|
+
const els = [el("a", 2, 3), el("b", 6, 1)];
|
|
90
|
+
const gap = resolveTrackGapAt(els, 1)!;
|
|
91
|
+
expect(resolveCloseGapShifts(els, gap)).toEqual([
|
|
92
|
+
{ key: "a", newStart: 0 },
|
|
93
|
+
{ key: "b", newStart: 4 },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("rounds shifted starts to millisecond precision", () => {
|
|
98
|
+
const els = [el("a", 0, 1.1), el("b", 3.3000000000000003, 1)];
|
|
99
|
+
const gap = resolveTrackGapAt(els, 2)!;
|
|
100
|
+
const shifts = resolveCloseGapShifts(els, gap);
|
|
101
|
+
expect(shifts).toEqual([{ key: "b", newStart: 1.1 }]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("resolveAllTrackGaps", () => {
|
|
106
|
+
it("compacts every gap: contiguous from 0, order and durations preserved", () => {
|
|
107
|
+
const els = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
|
|
108
|
+
expect(resolveAllTrackGaps(els)).toEqual([
|
|
109
|
+
{ key: "a", newStart: 0 },
|
|
110
|
+
{ key: "b", newStart: 2 },
|
|
111
|
+
{ key: "c", newStart: 5 },
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("includes the leading gap for a single clip", () => {
|
|
116
|
+
expect(resolveAllTrackGaps([el("a", 4, 2)])).toEqual([{ key: "a", newStart: 0 }]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns only the clips whose start actually changes", () => {
|
|
120
|
+
const els = [el("a", 0, 2), el("b", 2, 1), el("c", 5, 1)];
|
|
121
|
+
expect(resolveAllTrackGaps(els)).toEqual([{ key: "c", newStart: 3 }]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns [] for an already-contiguous track and for an empty lane", () => {
|
|
125
|
+
expect(resolveAllTrackGaps([el("a", 0, 2), el("b", 2, 3)])).toEqual([]);
|
|
126
|
+
expect(resolveAllTrackGaps([])).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("ignores epsilon-level drift instead of emitting no-op shifts", () => {
|
|
130
|
+
const els = [el("a", 0, 8.4), el("b", 8.4, 2.7), el("c", 11.100000000000001, 2)];
|
|
131
|
+
expect(resolveAllTrackGaps(els)).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("serializes overlapping clips in start order (sum-of-durations rule)", () => {
|
|
135
|
+
const els = [el("a", 0, 4), el("b", 2, 2)];
|
|
136
|
+
expect(resolveAllTrackGaps(els)).toEqual([{ key: "b", newStart: 4 }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("is deterministic for identical starts (key tie-break)", () => {
|
|
140
|
+
const els = [el("b", 3, 1), el("a", 3, 2)];
|
|
141
|
+
expect(resolveAllTrackGaps(els)).toEqual([
|
|
142
|
+
{ key: "a", newStart: 0 },
|
|
143
|
+
{ key: "b", newStart: 2 },
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("resolveAllGapIntervals", () => {
|
|
149
|
+
it("reports every current gap, leading gap included, left to right", () => {
|
|
150
|
+
const els = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
|
|
151
|
+
expect(resolveAllGapIntervals(els)).toEqual([
|
|
152
|
+
{ start: 0, end: 1 },
|
|
153
|
+
{ start: 3, end: 5 },
|
|
154
|
+
{ start: 8, end: 10 },
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns [] for contiguous or empty lanes", () => {
|
|
159
|
+
expect(resolveAllGapIntervals([el("a", 0, 2), el("b", 2, 3)])).toEqual([]);
|
|
160
|
+
expect(resolveAllGapIntervals([])).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("never fabricates an interval from overlapping clips (cursor = max end)", () => {
|
|
164
|
+
const els = [el("a", 0, 4), el("b", 1, 2), el("c", 6, 1)];
|
|
165
|
+
expect(resolveAllGapIntervals(els)).toEqual([{ start: 4, end: 6 }]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("ignores epsilon-level drift seams", () => {
|
|
169
|
+
const els = [el("a", 0, 8.4), el("b", 8.4, 2.7), el("c", 11.100000000000001, 2)];
|
|
170
|
+
expect(resolveAllGapIntervals(els)).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("trackHasGaps", () => {
|
|
175
|
+
it("detects gaps, including the leading gap", () => {
|
|
176
|
+
expect(trackHasGaps([el("a", 1, 2)])).toBe(true);
|
|
177
|
+
expect(trackHasGaps([el("a", 0, 2), el("b", 4, 1)])).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("is false for contiguous or empty tracks", () => {
|
|
181
|
+
expect(trackHasGaps([el("a", 0, 2), el("b", 2, 1)])).toBe(false);
|
|
182
|
+
expect(trackHasGaps([])).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("lane floor (expanded sub-comp children)", () => {
|
|
187
|
+
const child = (id: string, start: number, duration: number): TimelineElement => ({
|
|
188
|
+
...el(id, start, duration),
|
|
189
|
+
expandedParentStart: 16,
|
|
190
|
+
sourceFile: "scene.html",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("laneGapFloor is 0 for ordinary lanes and the host window start for child lanes", () => {
|
|
194
|
+
expect(laneGapFloor([el("a", 0, 2)])).toBe(0);
|
|
195
|
+
expect(laneGapFloor([child("c1", 16.5, 2), child("c2", 20, 2)])).toBe(16);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("compaction lands the first child at the HOST window start, never absolute 0", () => {
|
|
199
|
+
const lane = [child("c1", 18, 2), child("c2", 22, 2)];
|
|
200
|
+
expect(resolveAllTrackGaps(lane, undefined, laneGapFloor(lane))).toEqual([
|
|
201
|
+
{ key: "c1", newStart: 16 },
|
|
202
|
+
{ key: "c2", newStart: 18 },
|
|
203
|
+
]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("the leading gap starts at the floor for both close-one and the highlight intervals", () => {
|
|
207
|
+
const lane = [child("c1", 18, 2)];
|
|
208
|
+
const floor = laneGapFloor(lane);
|
|
209
|
+
expect(resolveTrackGapAt(lane, 17, undefined, floor)).toEqual({
|
|
210
|
+
gapStart: 16,
|
|
211
|
+
gapEnd: 18,
|
|
212
|
+
followingKeys: ["c1"],
|
|
213
|
+
});
|
|
214
|
+
expect(resolveAllGapIntervals(lane, undefined, floor)).toEqual([{ start: 16, end: 18 }]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("a child lane contiguous from its host start has no gaps", () => {
|
|
218
|
+
const lane = [child("c1", 16, 2), child("c2", 18, 2)];
|
|
219
|
+
expect(trackHasGaps(lane, undefined, laneGapFloor(lane))).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure gap math for a single timeline display lane (CapCut/Premiere-style
|
|
5
|
+
* "Close gap" / "Close all gaps"). Operates on the DISPLAY element set the
|
|
6
|
+
* timeline renders for one lane — the caller passes the clips of the
|
|
7
|
+
* right-clicked lane only; cross-lane behavior is out of scope by design.
|
|
8
|
+
*
|
|
9
|
+
* Conventions:
|
|
10
|
+
* - A clip occupies the half-open interval [start, start + duration).
|
|
11
|
+
* - Comparisons are epsilon-tolerant ({@link TRACK_GAP_EPSILON_S}) so float
|
|
12
|
+
* drift (e.g. 8.4 + 2.7 = 11.100000000000001) never fabricates a sliver gap.
|
|
13
|
+
* - Computed starts are rounded to millisecond precision, matching the drag
|
|
14
|
+
* commit's `round3`.
|
|
15
|
+
*/
|
|
16
|
+
const TRACK_GAP_EPSILON_S = 1e-3;
|
|
17
|
+
|
|
18
|
+
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The lane's time ORIGIN — the earliest start a clip on this lane may take.
|
|
22
|
+
* 0 for ordinary lanes; for a lane of expanded sub-comp children (post-
|
|
23
|
+
* collision-fix a lane is always single-origin) it is the children's host
|
|
24
|
+
* window start (`expandedParentStart`): display times are host-absolute, so
|
|
25
|
+
* compacting toward absolute 0 would drag a child BEFORE its host's window
|
|
26
|
+
* and persist a wrong (even negative) local time.
|
|
27
|
+
*/
|
|
28
|
+
export function laneGapFloor(elements: readonly TimelineElement[]): number {
|
|
29
|
+
return Math.max(0, ...elements.map((e) => e.expandedParentStart ?? 0));
|
|
30
|
+
}
|
|
31
|
+
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
32
|
+
const endOf = (e: TimelineElement) => e.start + e.duration;
|
|
33
|
+
|
|
34
|
+
/** Lane clips sorted by start (key as a deterministic tie-break). */
|
|
35
|
+
function sortedLaneClips(elements: readonly TimelineElement[]): TimelineElement[] {
|
|
36
|
+
return [...elements].sort((a, b) => a.start - b.start || keyOf(a).localeCompare(keyOf(b)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TrackGapAt {
|
|
40
|
+
/** Gap left edge: the max end of the clips left of the point (0 for the leading gap). */
|
|
41
|
+
gapStart: number;
|
|
42
|
+
/** Gap right edge: the start of the next clip on the lane. */
|
|
43
|
+
gapEnd: number;
|
|
44
|
+
/** Keys of the next clip and every clip after it on the lane, in start order. */
|
|
45
|
+
followingKeys: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the gap under a right-clicked point on one lane.
|
|
50
|
+
*
|
|
51
|
+
* Returns null when the point sits inside a clip, when there is no clip to the
|
|
52
|
+
* right of the point (nothing to close), or when the neighbouring clips are
|
|
53
|
+
* epsilon-adjacent (no real gap).
|
|
54
|
+
*/
|
|
55
|
+
export function resolveTrackGapAt(
|
|
56
|
+
elements: readonly TimelineElement[],
|
|
57
|
+
time: number,
|
|
58
|
+
epsilon: number = TRACK_GAP_EPSILON_S,
|
|
59
|
+
floor: number = 0,
|
|
60
|
+
): TrackGapAt | null {
|
|
61
|
+
const clips = sortedLaneClips(elements);
|
|
62
|
+
// Point inside a clip's half-open [start, end) → not empty space.
|
|
63
|
+
const occupied = clips.some((c) => time >= c.start - epsilon && time < endOf(c) - epsilon);
|
|
64
|
+
if (occupied) return null;
|
|
65
|
+
|
|
66
|
+
const following = clips.filter((c) => c.start > time - epsilon);
|
|
67
|
+
if (following.length === 0) return null; // nothing to the right — nothing to close
|
|
68
|
+
|
|
69
|
+
const gapEnd = following[0].start;
|
|
70
|
+
// Max end among clips left of the point (they all end at/before it since the
|
|
71
|
+
// point is unoccupied); the lane floor for the leading gap before the first
|
|
72
|
+
// clip (0 for ordinary lanes, the host window start for expanded children).
|
|
73
|
+
const gapStart = Math.max(
|
|
74
|
+
floor,
|
|
75
|
+
...clips.filter((c) => c.start <= time - epsilon).map((c) => endOf(c)),
|
|
76
|
+
);
|
|
77
|
+
if (gapEnd - gapStart <= epsilon) return null; // epsilon-adjacent — no gap
|
|
78
|
+
|
|
79
|
+
return { gapStart, gapEnd, followingKeys: following.map(keyOf) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TrackGapShift {
|
|
83
|
+
key: string;
|
|
84
|
+
newStart: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** An empty interval on the lane, [start, end) in seconds. */
|
|
88
|
+
export interface TrackGapInterval {
|
|
89
|
+
start: number;
|
|
90
|
+
end: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every CURRENT empty interval on the lane, leading gap included — the regions
|
|
95
|
+
* "Close all gaps" would collapse, in left-to-right order. Purely descriptive
|
|
96
|
+
* (for the hover-highlight overlay): unlike {@link resolveAllTrackGaps} it
|
|
97
|
+
* reports the gaps as they are now, not the post-compaction clip starts.
|
|
98
|
+
* Overlapping clips never fabricate a negative interval (the cursor tracks the
|
|
99
|
+
* max end seen so far).
|
|
100
|
+
*/
|
|
101
|
+
export function resolveAllGapIntervals(
|
|
102
|
+
elements: readonly TimelineElement[],
|
|
103
|
+
epsilon: number = TRACK_GAP_EPSILON_S,
|
|
104
|
+
floor: number = 0,
|
|
105
|
+
): TrackGapInterval[] {
|
|
106
|
+
const gaps: TrackGapInterval[] = [];
|
|
107
|
+
let cursor = floor;
|
|
108
|
+
for (const clip of sortedLaneClips(elements)) {
|
|
109
|
+
if (clip.start - cursor > epsilon) gaps.push({ start: cursor, end: clip.start });
|
|
110
|
+
cursor = Math.max(cursor, endOf(clip));
|
|
111
|
+
}
|
|
112
|
+
return gaps;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compact the whole lane: every clip lands at the sum of the durations of the
|
|
117
|
+
* clips before it (contiguous from 0, order and durations preserved).
|
|
118
|
+
* Overlapping clips (spill lanes) are serialized in start order — sane, if
|
|
119
|
+
* lossy for deliberate overlaps; the display lane set should not contain them.
|
|
120
|
+
*
|
|
121
|
+
* Returns ONLY the clips whose start actually changes (beyond epsilon).
|
|
122
|
+
*/
|
|
123
|
+
export function resolveAllTrackGaps(
|
|
124
|
+
elements: readonly TimelineElement[],
|
|
125
|
+
epsilon: number = TRACK_GAP_EPSILON_S,
|
|
126
|
+
floor: number = 0,
|
|
127
|
+
): TrackGapShift[] {
|
|
128
|
+
const shifts: TrackGapShift[] = [];
|
|
129
|
+
let cursor = floor;
|
|
130
|
+
for (const clip of sortedLaneClips(elements)) {
|
|
131
|
+
const newStart = round3(cursor);
|
|
132
|
+
if (Math.abs(newStart - clip.start) > epsilon) {
|
|
133
|
+
shifts.push({ key: keyOf(clip), newStart });
|
|
134
|
+
}
|
|
135
|
+
cursor += clip.duration;
|
|
136
|
+
}
|
|
137
|
+
return shifts;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every empty interval on the lane across the FULL rendered extent `[0, end)`:
|
|
142
|
+
* the closable gaps plus the open region after the last clip. Powers the
|
|
143
|
+
* click-selected lane highlight ("light the whole track except the clips") —
|
|
144
|
+
* unlike {@link resolveAllGapIntervals} it is not limited to what a gap-close
|
|
145
|
+
* could collapse.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveLaneEmptyIntervals(
|
|
148
|
+
elements: readonly TimelineElement[],
|
|
149
|
+
end: number,
|
|
150
|
+
epsilon: number = TRACK_GAP_EPSILON_S,
|
|
151
|
+
floor: number = 0,
|
|
152
|
+
): TrackGapInterval[] {
|
|
153
|
+
const gaps = resolveAllGapIntervals(elements, epsilon, floor);
|
|
154
|
+
const maxEnd = Math.max(floor, ...elements.map(endOf));
|
|
155
|
+
if (end - maxEnd > epsilon) gaps.push({ start: maxEnd, end });
|
|
156
|
+
return gaps;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Whether the lane has any gap "Close all gaps" would collapse. */
|
|
160
|
+
export function trackHasGaps(
|
|
161
|
+
elements: readonly TimelineElement[],
|
|
162
|
+
epsilon: number = TRACK_GAP_EPSILON_S,
|
|
163
|
+
floor: number = 0,
|
|
164
|
+
): boolean {
|
|
165
|
+
return resolveAllTrackGaps(elements, epsilon, floor).length > 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Per-clip shifts for closing ONE gap: the next clip and every clip after it
|
|
170
|
+
* on the lane move left by the gap's width. Starts are clamped at 0 (float
|
|
171
|
+
* safety; real shifts never cross the gap's own left edge).
|
|
172
|
+
*/
|
|
173
|
+
export function resolveCloseGapShifts(
|
|
174
|
+
elements: readonly TimelineElement[],
|
|
175
|
+
gap: TrackGapAt,
|
|
176
|
+
): TrackGapShift[] {
|
|
177
|
+
const width = gap.gapEnd - gap.gapStart;
|
|
178
|
+
const followSet = new Set(gap.followingKeys);
|
|
179
|
+
return sortedLaneClips(elements)
|
|
180
|
+
.filter((c) => followSet.has(keyOf(c)))
|
|
181
|
+
.map((c) => ({ key: keyOf(c), newStart: Math.max(gap.gapStart, round3(c.start - width)) }));
|
|
182
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
TRACKS_TOP_PAD,
|
|
6
6
|
TRACKS_BOTTOM_PAD,
|
|
7
7
|
GUTTER,
|
|
8
|
+
TRACKS_LEFT_PAD,
|
|
8
9
|
getTimelineRowTop,
|
|
9
10
|
getTimelineRowFromY,
|
|
10
11
|
getTimelineCanvasHeight,
|
|
@@ -85,7 +86,7 @@ describe("track-area breathing pad y-math", () => {
|
|
|
85
86
|
|
|
86
87
|
it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
|
|
87
88
|
const clientY = getTimelineRowTop(0) + TRACK_H / 2;
|
|
88
|
-
const clientX = GUTTER + 100; // t = 1s
|
|
89
|
+
const clientX = GUTTER + TRACKS_LEFT_PAD + 100; // t = 1s
|
|
89
90
|
const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
|
|
90
91
|
expect(track).toBe(0);
|
|
91
92
|
expect(start).toBe(1);
|