@hyperframes/studio 0.7.54 → 0.7.56
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/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +303 -0
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import {
|
|
4
|
+
buildTimelineGroupResizeMembers,
|
|
5
|
+
resolveTimelineGroupResize,
|
|
6
|
+
resolveTimelineGroupResizeChanges,
|
|
7
|
+
} from "./timelineGroupEditing";
|
|
8
|
+
|
|
9
|
+
function el(id: string, over: Partial<TimelineElement> = {}): TimelineElement {
|
|
10
|
+
// domId gives a patchable target so getTimelineEditCapabilities().canTrim* is true.
|
|
11
|
+
return { id, key: id, tag: "video", start: 0, duration: 2, track: 0, domId: id, ...over };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function keys(...ids: string[]): ReadonlySet<string> {
|
|
15
|
+
return new Set(ids);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
|
|
19
|
+
it("returns null for a single-clip selection (no group forms)", () => {
|
|
20
|
+
const a = el("a");
|
|
21
|
+
expect(buildTimelineGroupResizeMembers([a], keys("a"), "a", "end")).toBeNull();
|
|
22
|
+
expect(buildTimelineGroupResizeMembers([a], new Set(), "a", "end")).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns null when the grabbed clip is not part of the selection", () => {
|
|
26
|
+
const a = el("a");
|
|
27
|
+
const b = el("b");
|
|
28
|
+
expect(buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "c", "end")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("degrades to single-clip (null) when ANY member is locked — locked clip never patched", () => {
|
|
32
|
+
const a = el("a");
|
|
33
|
+
const locked = el("b", { timelineLocked: true });
|
|
34
|
+
expect(buildTimelineGroupResizeMembers([a, locked], keys("a", "b"), "a", "end")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("degrades when a member is implicitly timed or has no patch target", () => {
|
|
38
|
+
const a = el("a");
|
|
39
|
+
const implicit = el("b", { timingSource: "implicit" });
|
|
40
|
+
const noTarget = el("c", { domId: undefined, selector: undefined });
|
|
41
|
+
expect(buildTimelineGroupResizeMembers([a, implicit], keys("a", "b"), "a", "end")).toBeNull();
|
|
42
|
+
expect(buildTimelineGroupResizeMembers([a, noTarget], keys("a", "c"), "a", "end")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("snapshots members and seeds start-edge media playbackStart to 0", () => {
|
|
46
|
+
const grabbed = el("a", { start: 2, duration: 2 });
|
|
47
|
+
const audio = el("b", { tag: "audio", start: 5, duration: 3 }); // playbackStart undefined
|
|
48
|
+
const members = buildTimelineGroupResizeMembers([grabbed, audio], keys("a", "b"), "a", "start");
|
|
49
|
+
expect(members).not.toBeNull();
|
|
50
|
+
expect(members!.map((m) => [m.key, m.start, m.duration, m.playbackStart])).toEqual([
|
|
51
|
+
["a", 2, 2, 0], // start-edge media seeds playbackStart to 0
|
|
52
|
+
["b", 5, 3, 0],
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not seed playbackStart on the END edge", () => {
|
|
57
|
+
const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
|
|
58
|
+
const b = el("b", { tag: "audio", start: 3, duration: 2 });
|
|
59
|
+
const members = buildTimelineGroupResizeMembers([grabbed, b], keys("a", "b"), "a", "end");
|
|
60
|
+
expect(members!.every((m) => m.playbackStart === undefined)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("resolveTimelineGroupResizeChanges (rigid group patch set)", () => {
|
|
65
|
+
it("END edge: extends every member's duration by the shared delta", () => {
|
|
66
|
+
const members = buildTimelineGroupResizeMembers(
|
|
67
|
+
[el("a", { duration: 2 }), el("b", { duration: 3 })],
|
|
68
|
+
keys("a", "b"),
|
|
69
|
+
"a",
|
|
70
|
+
"end",
|
|
71
|
+
)!;
|
|
72
|
+
const changes = resolveTimelineGroupResizeChanges(members, "end", 0.5);
|
|
73
|
+
expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
|
|
74
|
+
["a", 0, 2.5],
|
|
75
|
+
["b", 0, 3.5],
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("END edge: rigid — the most-constrained member clamps the whole group", () => {
|
|
80
|
+
// b (0.2s) can only shrink 0.1 before hitting min duration, so the group as a
|
|
81
|
+
// whole shrinks 0.1 even though the grabbed clip asked for -0.5.
|
|
82
|
+
const members = buildTimelineGroupResizeMembers(
|
|
83
|
+
[el("a", { duration: 2 }), el("b", { duration: 0.2 })],
|
|
84
|
+
keys("a", "b"),
|
|
85
|
+
"a",
|
|
86
|
+
"end",
|
|
87
|
+
)!;
|
|
88
|
+
const changes = resolveTimelineGroupResizeChanges(members, "end", -0.5);
|
|
89
|
+
expect(changes.map((c) => c.duration)).toEqual([1.9, 0.1]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("START edge: shifts start + duration together across the group", () => {
|
|
93
|
+
const members = buildTimelineGroupResizeMembers(
|
|
94
|
+
[
|
|
95
|
+
el("a", { tag: "text", start: 2, duration: 2 }),
|
|
96
|
+
el("b", { tag: "text", start: 5, duration: 3 }),
|
|
97
|
+
],
|
|
98
|
+
keys("a", "b"),
|
|
99
|
+
"a",
|
|
100
|
+
"start",
|
|
101
|
+
)!;
|
|
102
|
+
const changes = resolveTimelineGroupResizeChanges(members, "start", -0.5);
|
|
103
|
+
expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
|
|
104
|
+
["a", 1.5, 2.5],
|
|
105
|
+
["b", 4.5, 3.5],
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("produces exactly the resolveTimelineGroupResize output, keyed per member", () => {
|
|
110
|
+
const members = buildTimelineGroupResizeMembers(
|
|
111
|
+
[el("a", { duration: 2 }), el("b", { duration: 4 })],
|
|
112
|
+
keys("a", "b"),
|
|
113
|
+
"a",
|
|
114
|
+
"end",
|
|
115
|
+
)!;
|
|
116
|
+
const raw = resolveTimelineGroupResize(members, "end", 0.75);
|
|
117
|
+
const changes = resolveTimelineGroupResizeChanges(members, "end", 0.75);
|
|
118
|
+
expect(
|
|
119
|
+
changes.map((c) => ({
|
|
120
|
+
start: c.start,
|
|
121
|
+
duration: c.duration,
|
|
122
|
+
playbackStart: c.playbackStart,
|
|
123
|
+
})),
|
|
124
|
+
).toEqual(raw.members);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { roundToCenti } from "../../utils/rounding";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
2
4
|
|
|
3
5
|
const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
|
|
4
6
|
const ABSOLUTE_TIMELINE_MIN_DURATION = 0.05;
|
|
@@ -157,3 +159,134 @@ export function resolveTimelineGroupResize(
|
|
|
157
159
|
}),
|
|
158
160
|
};
|
|
159
161
|
}
|
|
162
|
+
|
|
163
|
+
/* ── Multi-select group resize wiring (restored from main 36413da7f) ───────── */
|
|
164
|
+
|
|
165
|
+
/** A selected clip snapshot captured at group-resize gesture start. */
|
|
166
|
+
export interface TimelineGroupResizeMember extends TimelineGroupTimingMember {
|
|
167
|
+
element: TimelineElement;
|
|
168
|
+
key: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Per-member timing patch produced by a group resize (grabbed clip included). */
|
|
172
|
+
export interface TimelineGroupResizeChange {
|
|
173
|
+
element: TimelineElement;
|
|
174
|
+
key: string;
|
|
175
|
+
start: number;
|
|
176
|
+
duration: number;
|
|
177
|
+
playbackStart?: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function elementKey(element: TimelineElement): string {
|
|
181
|
+
return element.key ?? element.id;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isMediaTimelineElement(element: TimelineElement): boolean {
|
|
185
|
+
const tag = element.tag.toLowerCase();
|
|
186
|
+
return tag === "audio" || tag === "video";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean {
|
|
190
|
+
const caps = getTimelineEditCapabilities({
|
|
191
|
+
tag: element.tag,
|
|
192
|
+
duration: element.duration,
|
|
193
|
+
domId: element.domId,
|
|
194
|
+
selector: element.selector,
|
|
195
|
+
compositionSrc: element.compositionSrc,
|
|
196
|
+
playbackStart: element.playbackStart,
|
|
197
|
+
playbackStartAttr: element.playbackStartAttr,
|
|
198
|
+
sourceDuration: element.sourceDuration,
|
|
199
|
+
timingSource: element.timingSource,
|
|
200
|
+
timelineLocked: element.timelineLocked,
|
|
201
|
+
});
|
|
202
|
+
return edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Snapshot the members for a group resize, or `null` when the gesture must stay a
|
|
207
|
+
* single-clip resize. Legacy semantics (main 36413da7f): a group forms only when
|
|
208
|
+
* the grabbed clip is part of a multi-selection (> 1) AND EVERY selected member
|
|
209
|
+
* can take THIS edge's trim. It is all-or-nothing — if any member is locked or
|
|
210
|
+
* implicitly-timed the group does not form, so a locked member is never patched
|
|
211
|
+
* (the grabbed clip then resizes on its own). Start-edge media members seed
|
|
212
|
+
* `playbackStart` to 0 so the in-point trim math has a base, mirroring the legacy
|
|
213
|
+
* `resizeMember`.
|
|
214
|
+
*/
|
|
215
|
+
export function buildTimelineGroupResizeMembers(
|
|
216
|
+
elements: readonly TimelineElement[],
|
|
217
|
+
selectedKeys: ReadonlySet<string>,
|
|
218
|
+
grabbedKey: string,
|
|
219
|
+
edge: TimelineGroupResizeEdge,
|
|
220
|
+
): TimelineGroupResizeMember[] | null {
|
|
221
|
+
if (selectedKeys.size <= 1 || !selectedKeys.has(grabbedKey)) return null;
|
|
222
|
+
const selected = elements.filter((element) => selectedKeys.has(elementKey(element)));
|
|
223
|
+
if (selected.length <= 1) return null;
|
|
224
|
+
if (!selected.every((element) => canTrimEdge(element, edge))) return null;
|
|
225
|
+
return selected.map((element) => ({
|
|
226
|
+
element,
|
|
227
|
+
key: elementKey(element),
|
|
228
|
+
start: element.start,
|
|
229
|
+
duration: element.duration,
|
|
230
|
+
playbackStart:
|
|
231
|
+
edge === "start" && isMediaTimelineElement(element)
|
|
232
|
+
? (element.playbackStart ?? 0)
|
|
233
|
+
: element.playbackStart,
|
|
234
|
+
playbackRate: element.playbackRate,
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve the per-member timing patches for a group resize from the grabbed
|
|
240
|
+
* clip's raw edge delta. Rigid group: one shared delta, clamped by the most
|
|
241
|
+
* constrained member (see resolveTimelineGroupResize), applied to every member.
|
|
242
|
+
*/
|
|
243
|
+
export function resolveTimelineGroupResizeChanges(
|
|
244
|
+
members: readonly TimelineGroupResizeMember[],
|
|
245
|
+
edge: TimelineGroupResizeEdge,
|
|
246
|
+
rawDelta: number,
|
|
247
|
+
minDuration = resolveTimelineMinDuration(),
|
|
248
|
+
): TimelineGroupResizeChange[] {
|
|
249
|
+
const result = resolveTimelineGroupResize(members, edge, rawDelta, minDuration);
|
|
250
|
+
return result.members.map((member, index) => ({
|
|
251
|
+
element: members[index]!.element,
|
|
252
|
+
key: members[index]!.key,
|
|
253
|
+
start: member.start,
|
|
254
|
+
duration: member.duration,
|
|
255
|
+
playbackStart: member.playbackStart,
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** In-flight multi-select group-resize gesture (owned by the drag hook). */
|
|
260
|
+
export interface TimelineGroupResizeSession {
|
|
261
|
+
grabbedKey: string;
|
|
262
|
+
edge: TimelineGroupResizeEdge;
|
|
263
|
+
members: TimelineGroupResizeMember[];
|
|
264
|
+
changes: TimelineGroupResizeChange[];
|
|
265
|
+
hasChanged: boolean;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Fold the grabbed clip's single-clip preview into the group: derive the raw
|
|
270
|
+
* edge delta from it, resolve the rigid per-member changes onto the session
|
|
271
|
+
* (updating `changes` + `hasChanged`), and return the grabbed clip's own change
|
|
272
|
+
* so the caller can render it from the resize state.
|
|
273
|
+
*/
|
|
274
|
+
export function applyTimelineGroupResizePreview(
|
|
275
|
+
session: TimelineGroupResizeSession,
|
|
276
|
+
grabbedPreview: { previewStart: number; previewDuration: number },
|
|
277
|
+
): TimelineGroupResizeChange | undefined {
|
|
278
|
+
const grabbed = session.members.find((m) => m.key === session.grabbedKey);
|
|
279
|
+
const rawDelta =
|
|
280
|
+
session.edge === "start"
|
|
281
|
+
? grabbedPreview.previewStart - (grabbed?.start ?? grabbedPreview.previewStart)
|
|
282
|
+
: grabbedPreview.previewDuration - (grabbed?.duration ?? grabbedPreview.previewDuration);
|
|
283
|
+
const changes = resolveTimelineGroupResizeChanges(session.members, session.edge, rawDelta);
|
|
284
|
+
session.changes = changes;
|
|
285
|
+
session.hasChanged = changes.some(
|
|
286
|
+
(c, i) =>
|
|
287
|
+
c.start !== session.members[i]!.start ||
|
|
288
|
+
c.duration !== session.members[i]!.duration ||
|
|
289
|
+
c.playbackStart !== session.members[i]!.playbackStart,
|
|
290
|
+
);
|
|
291
|
+
return changes.find((c) => c.key === session.grabbedKey);
|
|
292
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
3
|
+
import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
|
|
4
|
+
import {
|
|
5
|
+
beginTimelineOptimisticGesture,
|
|
6
|
+
rollbackLatestTimelineOptimisticGesture,
|
|
7
|
+
} from "./timelineOptimisticRevision";
|
|
8
|
+
|
|
9
|
+
export function commitTimelineGroupResize(
|
|
10
|
+
session: TimelineGroupResizeSession,
|
|
11
|
+
updateElement: (key: string, updates: Partial<TimelineElement>) => void,
|
|
12
|
+
persist: TimelineEditCallbacks["onResizeElements"],
|
|
13
|
+
): void {
|
|
14
|
+
if (!session.hasChanged) return;
|
|
15
|
+
const changes = session.changes;
|
|
16
|
+
const revision = beginTimelineOptimisticGesture(
|
|
17
|
+
updateElement,
|
|
18
|
+
changes.map((change) => change.key),
|
|
19
|
+
);
|
|
20
|
+
for (const change of changes) {
|
|
21
|
+
updateElement(change.key, {
|
|
22
|
+
start: change.start,
|
|
23
|
+
duration: change.duration,
|
|
24
|
+
playbackStart: change.playbackStart,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (!persist) {
|
|
28
|
+
rollbackLatestTimelineOptimisticGesture(
|
|
29
|
+
updateElement,
|
|
30
|
+
revision,
|
|
31
|
+
session.members.map((member) => ({ key: member.key, updates: member })),
|
|
32
|
+
);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const coalesceKey = `clip-group-resize:${changes.map((change) => change.key).join(":")}`;
|
|
36
|
+
Promise.resolve(
|
|
37
|
+
persist(
|
|
38
|
+
changes.map((change) => ({
|
|
39
|
+
element: change.element,
|
|
40
|
+
start: change.start,
|
|
41
|
+
duration: change.duration,
|
|
42
|
+
playbackStart: change.playbackStart,
|
|
43
|
+
})),
|
|
44
|
+
{ coalesceKey },
|
|
45
|
+
),
|
|
46
|
+
).catch((error) => {
|
|
47
|
+
rollbackLatestTimelineOptimisticGesture(
|
|
48
|
+
updateElement,
|
|
49
|
+
revision,
|
|
50
|
+
session.members.map((member) => ({ key: member.key, updates: member })),
|
|
51
|
+
);
|
|
52
|
+
console.error("[Timeline] Failed to persist group clip resize", error);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -1,34 +1,106 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
RULER_H,
|
|
4
|
+
TRACK_H,
|
|
5
|
+
TRACKS_TOP_PAD,
|
|
6
|
+
TRACKS_BOTTOM_PAD,
|
|
7
|
+
GUTTER,
|
|
8
|
+
getTimelineRowTop,
|
|
9
|
+
getTimelineRowFromY,
|
|
10
|
+
getTimelineCanvasHeight,
|
|
11
|
+
resolveTimelineAssetDrop,
|
|
12
|
+
} from "./timelineLayout";
|
|
3
13
|
|
|
4
|
-
describe("
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
14
|
+
describe("track-area breathing pad y-math", () => {
|
|
15
|
+
describe("getTimelineRowTop", () => {
|
|
16
|
+
it("offsets the first lane below the ruler by the top pad", () => {
|
|
17
|
+
expect(getTimelineRowTop(0)).toBe(RULER_H + TRACKS_TOP_PAD);
|
|
18
|
+
});
|
|
8
19
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
20
|
+
it("advances by one track height per row, keeping the pad", () => {
|
|
21
|
+
expect(getTimelineRowTop(1)).toBe(RULER_H + TRACKS_TOP_PAD + TRACK_H);
|
|
22
|
+
expect(getTimelineRowTop(3)).toBe(RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H);
|
|
23
|
+
});
|
|
12
24
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
it("is a strict positive shift from the pre-pad formula (pad is non-zero)", () => {
|
|
26
|
+
expect(TRACKS_TOP_PAD).toBeGreaterThan(0);
|
|
27
|
+
expect(getTimelineRowTop(2)).toBe(RULER_H + 2 * TRACK_H + TRACKS_TOP_PAD);
|
|
28
|
+
});
|
|
16
29
|
});
|
|
17
|
-
});
|
|
18
30
|
|
|
19
|
-
describe("
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
describe("getTimelineRowFromY", () => {
|
|
32
|
+
it("is the exact inverse of getTimelineRowTop at lane boundaries", () => {
|
|
33
|
+
for (const row of [0, 1, 2, 7]) {
|
|
34
|
+
expect(getTimelineRowFromY(getTimelineRowTop(row))).toBeCloseTo(row, 10);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("floors a y inside the top pad (above lane 0) to a negative fraction", () => {
|
|
39
|
+
// A drop in the pad between the ruler and lane 0 sits at row < 0, so a
|
|
40
|
+
// floor lands it on row -1 → getDefaultDroppedTrack floors to the top lane.
|
|
41
|
+
const yInPad = RULER_H + TRACKS_TOP_PAD / 2;
|
|
42
|
+
expect(getTimelineRowFromY(yInPad)).toBeLessThan(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("maps a y in the middle of lane 1 into [1,2)", () => {
|
|
46
|
+
const yMidLane1 = getTimelineRowTop(1) + TRACK_H / 2;
|
|
47
|
+
const row = getTimelineRowFromY(yMidLane1);
|
|
48
|
+
expect(row).toBeGreaterThanOrEqual(1);
|
|
49
|
+
expect(row).toBeLessThan(2);
|
|
50
|
+
});
|
|
22
51
|
});
|
|
23
52
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
53
|
+
describe("getTimelineCanvasHeight", () => {
|
|
54
|
+
it("reserves ruler + top pad + lanes + bottom pad", () => {
|
|
55
|
+
expect(getTimelineCanvasHeight(0)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
|
|
56
|
+
expect(getTimelineCanvasHeight(3)).toBe(
|
|
57
|
+
RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + TRACKS_BOTTOM_PAD,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("clamps a negative track count to zero lanes", () => {
|
|
62
|
+
expect(getTimelineCanvasHeight(-4)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("leaves room below the last lane for a drag-into-void new track", () => {
|
|
66
|
+
// The gap below the final lane must be at least a full track height so a
|
|
67
|
+
// clip can be dropped there to create a new bottom track.
|
|
68
|
+
const oneLane = getTimelineCanvasHeight(1);
|
|
69
|
+
const lastLaneBottom = getTimelineRowTop(0) + TRACK_H;
|
|
70
|
+
expect(oneLane - lastLaneBottom).toBeGreaterThanOrEqual(TRACK_H);
|
|
71
|
+
});
|
|
27
72
|
});
|
|
28
73
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
74
|
+
describe("resolveTimelineAssetDrop honours the top pad", () => {
|
|
75
|
+
const base = {
|
|
76
|
+
rectLeft: 0,
|
|
77
|
+
rectTop: 0,
|
|
78
|
+
scrollLeft: 0,
|
|
79
|
+
scrollTop: 0,
|
|
80
|
+
pixelsPerSecond: 100,
|
|
81
|
+
duration: 60,
|
|
82
|
+
trackHeight: TRACK_H,
|
|
83
|
+
trackOrder: [0, 1, 2],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
|
|
87
|
+
const clientY = getTimelineRowTop(0) + TRACK_H / 2;
|
|
88
|
+
const clientX = GUTTER + 100; // t = 1s
|
|
89
|
+
const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
|
|
90
|
+
expect(track).toBe(0);
|
|
91
|
+
expect(start).toBe(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("drops into the top pad → floors to the first lane (row < 0)", () => {
|
|
95
|
+
const clientY = RULER_H + TRACKS_TOP_PAD / 2; // inside the pad, above lane 0
|
|
96
|
+
const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
|
|
97
|
+
expect(track).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("drops below the last lane → appends a new track", () => {
|
|
101
|
+
const clientY = getTimelineRowTop(2) + TRACK_H + 4; // in the bottom pad
|
|
102
|
+
const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
|
|
103
|
+
expect(track).toBe(3); // max(trackOrder)+1
|
|
104
|
+
});
|
|
33
105
|
});
|
|
34
106
|
});
|