@hyperframes/studio 0.7.61 → 0.7.62
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-Xvx2hkrc.js → hyperframes-player-CWpmBjzv.js} +1 -1
- package/dist/assets/{index-BCpoiv9S.js → index-BJzSiIBj.js} +1 -1
- package/dist/assets/index-Ceyz8Qt2.css +1 -0
- package/dist/assets/{index-D-uFclFj.js → index-DJQPB78e.js} +1 -1
- package/dist/assets/index-vDRVQtTK.js +426 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3504 -2955
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +15 -10
- package/src/components/EditorShell.tsx +12 -0
- package/src/components/StudioLeftSidebar.tsx +3 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
- package/src/components/editor/PropertyPanelFlat.tsx +6 -0
- package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
- package/src/components/editor/domEditing.test.ts +34 -0
- package/src/components/editor/propertyPanelColor.test.tsx +31 -0
- package/src/components/editor/propertyPanelColor.tsx +150 -28
- package/src/components/editor/propertyPanelCommitField.tsx +178 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +34 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +5 -1
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +6 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
- package/src/components/editor/propertyPanelFlatTextSection.tsx +8 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +4 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -100
- package/src/components/editor/propertyPanelSections.tsx +2 -2
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +6 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
- package/src/components/sidebar/CompositionsTab.tsx +45 -2
- package/src/components/sidebar/LeftSidebar.tsx +3 -0
- package/src/contexts/StudioContext.tsx +4 -0
- package/src/hooks/timelineEditingHelpers.ts +10 -4
- package/src/hooks/usePreviewPersistence.ts +7 -1
- package/src/hooks/useRazorSplit.history.test.tsx +21 -28
- package/src/hooks/useRazorSplit.test.tsx +20 -9
- package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
- package/src/hooks/useRazorSplit.ts +78 -350
- package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +52 -2
- package/src/hooks/useTimelineEditing.ts +19 -17
- package/src/hooks/useTimelineGroupEditing.ts +6 -7
- package/src/player/components/Timeline.tsx +12 -1
- package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
- package/src/player/components/timelineAuthoredTrack.ts +29 -0
- package/src/player/components/timelineCallbacks.ts +4 -0
- package/src/player/components/timelineClipDragCommit.test.ts +176 -0
- package/src/player/components/timelineClipDragCommit.ts +77 -105
- package/src/player/components/timelineClipDragPreview.test.ts +33 -1
- package/src/player/components/timelineClipDragPreview.ts +2 -1
- package/src/player/components/timelineCollision.test.ts +18 -0
- package/src/player/components/timelineCollision.ts +12 -9
- package/src/player/components/timelineDragDrop.ts +28 -7
- package/src/player/components/timelineEditCapabilities.ts +4 -1
- package/src/player/components/timelineGroupEditing.test.ts +10 -0
- package/src/player/components/timelineGroupEditing.ts +4 -3
- package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
- package/src/player/components/timelineLayout.ts +3 -1
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.ts +5 -0
- package/src/player/components/useTimelineEditPinning.ts +12 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/lib/playbackTypes.ts +2 -0
- package/src/player/lib/timelineDOM.test.ts +58 -0
- package/src/player/lib/timelineDOM.ts +18 -1
- package/src/player/lib/timelineElementHelpers.ts +21 -11
- package/src/player/store/playerStore.ts +2 -1
- package/src/utils/razorSplitTransaction.test.ts +175 -0
- package/src/utils/razorSplitTransaction.ts +200 -0
- package/src/utils/studioPreviewHelpers.test.ts +27 -0
- package/src/utils/timelineCompositionDrop.test.ts +12 -0
- package/src/utils/timelineCompositionDrop.ts +16 -0
- package/src/utils/timelineCompositionInsert.test.ts +142 -0
- package/src/utils/timelineCompositionInsert.ts +90 -0
- package/src/utils/timelineElementSplit.test.ts +14 -2
- package/src/utils/timelineElementSplit.ts +5 -1
- package/dist/assets/index-2mxh_HSy.js +0 -426
- package/dist/assets/index-BhWig0mx.css +0 -1
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
computeDragPreview,
|
|
5
|
+
computeResizePreview,
|
|
6
|
+
type DragPreviewContext,
|
|
7
|
+
} from "./timelineClipDragPreview";
|
|
4
8
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
5
9
|
import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
|
|
6
10
|
|
|
@@ -142,3 +146,31 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
|
|
|
142
146
|
expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
|
|
143
147
|
});
|
|
144
148
|
});
|
|
149
|
+
|
|
150
|
+
describe("computeResizePreview — composition source continuity", () => {
|
|
151
|
+
it("seeds a legacy composition offset and advances it at playback rate", () => {
|
|
152
|
+
const element = {
|
|
153
|
+
...clip("comp", 0, 2, 4, 0, "div"),
|
|
154
|
+
kind: "composition" as const,
|
|
155
|
+
playbackRate: 2,
|
|
156
|
+
};
|
|
157
|
+
const result = computeResizePreview(
|
|
158
|
+
{
|
|
159
|
+
element,
|
|
160
|
+
edge: "start",
|
|
161
|
+
originClientX: 0,
|
|
162
|
+
previewStart: 2,
|
|
163
|
+
previewDuration: 4,
|
|
164
|
+
started: true,
|
|
165
|
+
},
|
|
166
|
+
100,
|
|
167
|
+
{ scroll: fakeScroll(), pps: 100, buildSnapTargets: () => [] },
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(result).toMatchObject({
|
|
171
|
+
previewStart: 3,
|
|
172
|
+
previewDuration: 3,
|
|
173
|
+
previewPlaybackStart: 2,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -221,7 +221,8 @@ export function computeResizePreview(
|
|
|
221
221
|
)
|
|
222
222
|
: Number.POSITIVE_INFINITY;
|
|
223
223
|
const normalizedTag = resize.element.tag.toLowerCase();
|
|
224
|
-
const canSeedPlaybackStart =
|
|
224
|
+
const canSeedPlaybackStart =
|
|
225
|
+
resize.element.kind === "composition" || normalizedTag === "audio" || normalizedTag === "video";
|
|
225
226
|
const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
|
|
226
227
|
// Trim limit = available source media only — NOT the composition length.
|
|
227
228
|
// Duration is content-driven (the comp grows/shrinks to fit on commit), so
|
|
@@ -308,6 +308,24 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
|
|
|
308
308
|
).toEqual({ track: 2, insertRow: null });
|
|
309
309
|
});
|
|
310
310
|
|
|
311
|
+
it("crosses an occupied aim instead of snapping the dragged clip back to its origin", () => {
|
|
312
|
+
expect(
|
|
313
|
+
resolveZoneDropPlacement({
|
|
314
|
+
...base,
|
|
315
|
+
elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("x", 2, 0, 5)],
|
|
316
|
+
desiredTrack: 1,
|
|
317
|
+
}),
|
|
318
|
+
).toEqual({ track: 1, insertRow: 1 });
|
|
319
|
+
|
|
320
|
+
expect(
|
|
321
|
+
resolveZoneDropPlacement({
|
|
322
|
+
...base,
|
|
323
|
+
elements: [el("x", 0, 0, 5), el("b", 1, 0, 5), el("a", 2, 0, 5)],
|
|
324
|
+
desiredTrack: 1,
|
|
325
|
+
}),
|
|
326
|
+
).toEqual({ track: 1, insertRow: 2 });
|
|
327
|
+
});
|
|
328
|
+
|
|
311
329
|
it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => {
|
|
312
330
|
expect(
|
|
313
331
|
resolveZoneDropPlacement({
|
|
@@ -115,7 +115,10 @@ export function resolveZoneDropPlacement(input: {
|
|
|
115
115
|
trackOrder: zoneTracks,
|
|
116
116
|
excludeKey: dragKey,
|
|
117
117
|
});
|
|
118
|
-
|
|
118
|
+
const originTrack = elements.find((element) => (element.key ?? element.id) === dragKey)?.track;
|
|
119
|
+
const snappedBackToOrigin =
|
|
120
|
+
originTrack != null && desired !== originTrack && placement.track === originTrack;
|
|
121
|
+
if (placement.needsInsert || snappedBackToOrigin) {
|
|
119
122
|
const desiredRow = order.indexOf(desired);
|
|
120
123
|
if (desiredRow < 0) {
|
|
121
124
|
return {
|
|
@@ -123,14 +126,14 @@ export function resolveZoneDropPlacement(input: {
|
|
|
123
126
|
insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
|
|
124
127
|
};
|
|
125
128
|
}
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
// When collision fallback found only the origin lane, insert on the far side
|
|
130
|
+
// of the aimed lane so normalization cannot turn the gesture into a no-op.
|
|
131
|
+
// Otherwise prefer the gap nearest the pointer, preserving normal insertion.
|
|
132
|
+
const originRow = originTrack == null ? -1 : order.indexOf(originTrack);
|
|
133
|
+
const insertAbove = snappedBackToOrigin
|
|
134
|
+
? originRow > desiredRow
|
|
135
|
+
: preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio);
|
|
136
|
+
const insertRow = insertAbove ? desiredRow : desiredRow + 1;
|
|
134
137
|
return { track: desired, insertRow };
|
|
135
138
|
}
|
|
136
139
|
return { track: placement.track, insertRow: null };
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useCallback, useState, type RefObject } from "react";
|
|
2
2
|
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
3
|
+
import {
|
|
4
|
+
parseTimelineCompositionPayload,
|
|
5
|
+
TIMELINE_COMPOSITION_MIME,
|
|
6
|
+
} from "../../utils/timelineCompositionDrop";
|
|
3
7
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
8
|
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
|
|
5
9
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
@@ -32,6 +36,11 @@ function applyJsonDropPayload(
|
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
function resolveDropStart(usePointerStart: boolean, pointerStart: number): number {
|
|
40
|
+
if (usePointerStart) return pointerStart;
|
|
41
|
+
return Math.max(0, usePlayerStore.getState().currentTime);
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
/**
|
|
36
45
|
* Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
|
|
37
46
|
* start is the current playhead time, only the track comes from the drop y.
|
|
@@ -48,6 +57,7 @@ export function useTimelineAssetDrop({
|
|
|
48
57
|
onFileDrop,
|
|
49
58
|
onAssetDrop,
|
|
50
59
|
onBlockDrop,
|
|
60
|
+
onCompositionDrop,
|
|
51
61
|
}: UseTimelineAssetDropOptions) {
|
|
52
62
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
53
63
|
|
|
@@ -56,7 +66,8 @@ export function useTimelineAssetDrop({
|
|
|
56
66
|
const hasFiles = types.includes("Files");
|
|
57
67
|
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
|
58
68
|
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
|
59
|
-
|
|
69
|
+
const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
|
|
70
|
+
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
|
60
71
|
e.preventDefault();
|
|
61
72
|
e.dataTransfer.dropEffect = "copy";
|
|
62
73
|
setIsDragOver(true);
|
|
@@ -65,11 +76,10 @@ export function useTimelineAssetDrop({
|
|
|
65
76
|
const clearDropPreview = useCallback(() => setIsDragOver(false), []);
|
|
66
77
|
|
|
67
78
|
const resolveDropPlacement = useCallback(
|
|
68
|
-
(clientX: number, clientY: number): TimelinePlacement => {
|
|
79
|
+
(clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
|
|
69
80
|
const scroll = scrollRef.current;
|
|
70
81
|
const rect = scroll?.getBoundingClientRect();
|
|
71
|
-
|
|
72
|
-
const { track } = resolveTimelineAssetDrop(
|
|
82
|
+
const pointer = resolveTimelineAssetDrop(
|
|
73
83
|
{
|
|
74
84
|
rectLeft: rect?.left ?? 0,
|
|
75
85
|
rectTop: rect?.top ?? 0,
|
|
@@ -77,14 +87,17 @@ export function useTimelineAssetDrop({
|
|
|
77
87
|
scrollTop: scroll?.scrollTop ?? 0,
|
|
78
88
|
pixelsPerSecond: ppsRef.current,
|
|
79
89
|
duration: durationRef.current,
|
|
90
|
+
clampStartToDuration: !usePointerStart,
|
|
80
91
|
trackHeight: TRACK_H,
|
|
81
92
|
trackOrder: trackOrderRef.current,
|
|
82
93
|
},
|
|
83
94
|
clientX,
|
|
84
95
|
clientY,
|
|
85
96
|
);
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
return {
|
|
98
|
+
start: resolveDropStart(usePointerStart, pointer.start),
|
|
99
|
+
track: pointer.track,
|
|
100
|
+
};
|
|
88
101
|
},
|
|
89
102
|
[scrollRef, ppsRef, durationRef, trackOrderRef],
|
|
90
103
|
);
|
|
@@ -93,6 +106,14 @@ export function useTimelineAssetDrop({
|
|
|
93
106
|
(e: React.DragEvent) => {
|
|
94
107
|
e.preventDefault();
|
|
95
108
|
setIsDragOver(false);
|
|
109
|
+
const compositionPayload = parseTimelineCompositionPayload(
|
|
110
|
+
e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
|
|
111
|
+
);
|
|
112
|
+
if (compositionPayload && onCompositionDrop) {
|
|
113
|
+
const placement = resolveDropPlacement(e.clientX, e.clientY, true);
|
|
114
|
+
void onCompositionDrop(compositionPayload.sourcePath, placement);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
96
117
|
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
|
97
118
|
|
|
98
119
|
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
@@ -109,7 +130,7 @@ export function useTimelineAssetDrop({
|
|
|
109
130
|
applyJsonDropPayload(blockPayload, (p) => p.name, onBlockDrop, placement);
|
|
110
131
|
}
|
|
111
132
|
},
|
|
112
|
-
[resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop],
|
|
133
|
+
[resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop, onCompositionDrop],
|
|
113
134
|
);
|
|
114
135
|
|
|
115
136
|
return { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview };
|
|
@@ -6,11 +6,13 @@ export interface TimelineEditCapabilities {
|
|
|
6
6
|
|
|
7
7
|
function isDeterministicTimelineWindow(input: {
|
|
8
8
|
tag: string;
|
|
9
|
+
kind?: "video" | "audio" | "image" | "element" | "composition";
|
|
9
10
|
compositionSrc?: string;
|
|
10
11
|
playbackStartAttr?: "media-start" | "playback-start";
|
|
11
12
|
sourceDuration?: number;
|
|
12
13
|
}): boolean {
|
|
13
|
-
if (input.compositionSrc || input.playbackStartAttr != null)
|
|
14
|
+
if (input.kind === "composition" || input.compositionSrc || input.playbackStartAttr != null)
|
|
15
|
+
return true;
|
|
14
16
|
if (
|
|
15
17
|
input.sourceDuration != null &&
|
|
16
18
|
Number.isFinite(input.sourceDuration) &&
|
|
@@ -27,6 +29,7 @@ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: s
|
|
|
27
29
|
|
|
28
30
|
export function getTimelineEditCapabilities(input: {
|
|
29
31
|
tag: string;
|
|
32
|
+
kind?: "video" | "audio" | "image" | "element" | "composition";
|
|
30
33
|
duration: number;
|
|
31
34
|
domId?: string;
|
|
32
35
|
selector?: string;
|
|
@@ -53,6 +53,16 @@ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
|
|
|
53
53
|
]);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
it("seeds legacy composition offsets and advances them at playback rate", () => {
|
|
57
|
+
const a = el("a", { kind: "composition", tag: "div", start: 2, playbackRate: 2 });
|
|
58
|
+
const b = el("b", { kind: "composition", tag: "div", start: 5, playbackRate: 0.5 });
|
|
59
|
+
const members = buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "a", "start")!;
|
|
60
|
+
|
|
61
|
+
expect(members.map((member) => member.playbackStart)).toEqual([0, 0]);
|
|
62
|
+
const changes = resolveTimelineGroupResizeChanges(members, "start", 1);
|
|
63
|
+
expect(changes.map((change) => change.playbackStart)).toEqual([2, 0.5]);
|
|
64
|
+
});
|
|
65
|
+
|
|
56
66
|
it("does not seed playbackStart on the END edge", () => {
|
|
57
67
|
const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
|
|
58
68
|
const b = el("b", { tag: "audio", start: 3, duration: 2 });
|
|
@@ -181,14 +181,15 @@ function elementKey(element: TimelineElement): string {
|
|
|
181
181
|
return element.key ?? element.id;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
function
|
|
184
|
+
function hasSourcePlaybackOffset(element: TimelineElement): boolean {
|
|
185
185
|
const tag = element.tag.toLowerCase();
|
|
186
|
-
return tag === "audio" || tag === "video";
|
|
186
|
+
return element.kind === "composition" || tag === "audio" || tag === "video";
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean {
|
|
190
190
|
const caps = getTimelineEditCapabilities({
|
|
191
191
|
tag: element.tag,
|
|
192
|
+
kind: element.kind,
|
|
192
193
|
duration: element.duration,
|
|
193
194
|
domId: element.domId,
|
|
194
195
|
selector: element.selector,
|
|
@@ -228,7 +229,7 @@ export function buildTimelineGroupResizeMembers(
|
|
|
228
229
|
start: element.start,
|
|
229
230
|
duration: element.duration,
|
|
230
231
|
playbackStart:
|
|
231
|
-
edge === "start" &&
|
|
232
|
+
edge === "start" && hasSourcePlaybackOffset(element)
|
|
232
233
|
? (element.playbackStart ?? 0)
|
|
233
234
|
: element.playbackStart,
|
|
234
235
|
playbackRate: element.playbackRate,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface LaneMoveRefreshDeps {
|
|
2
|
+
refreshAfterLaneMove?: () => void;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Refresh only after the complete lane transaction persisted successfully. */
|
|
6
|
+
export function refreshAfterDurableLaneMove(
|
|
7
|
+
pending: Promise<boolean>,
|
|
8
|
+
deps: LaneMoveRefreshDeps,
|
|
9
|
+
): Promise<boolean> {
|
|
10
|
+
return pending.then((persisted) => {
|
|
11
|
+
if (persisted) deps.refreshAfterLaneMove?.();
|
|
12
|
+
return persisted;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -383,6 +383,7 @@ export function resolveTimelineAssetDrop(
|
|
|
383
383
|
scrollTop: number;
|
|
384
384
|
pixelsPerSecond: number;
|
|
385
385
|
duration: number;
|
|
386
|
+
clampStartToDuration?: boolean;
|
|
386
387
|
trackHeight: number;
|
|
387
388
|
trackOrder: number[];
|
|
388
389
|
},
|
|
@@ -391,9 +392,10 @@ export function resolveTimelineAssetDrop(
|
|
|
391
392
|
): { start: number; track: number } {
|
|
392
393
|
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
|
393
394
|
const contentY = clientY - input.rectTop + input.scrollTop;
|
|
395
|
+
const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
|
|
394
396
|
const start = Math.max(
|
|
395
397
|
0,
|
|
396
|
-
|
|
398
|
+
input.clampStartToDuration === false ? pointerStart : Math.min(input.duration, pointerStart),
|
|
397
399
|
);
|
|
398
400
|
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
|
|
399
401
|
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
2
|
import { classifyZone } from "./timelineZones";
|
|
3
3
|
import { isLaneFree, timeRangesOverlap } from "./timelineCollision";
|
|
4
|
-
import { authoredTrackForLane, sameSourceFile } from "./
|
|
4
|
+
import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
|
|
5
5
|
import { samePaintScope } from "./timelineStackingSync";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -77,6 +77,7 @@ interface UseTimelineClipDragInput {
|
|
|
77
77
|
*/
|
|
78
78
|
readZIndex?: (element: TimelineElement) => number;
|
|
79
79
|
onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
|
|
80
|
+
refreshAfterLaneMove?: () => void;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export function useTimelineClipDrag({
|
|
@@ -93,6 +94,7 @@ export function useTimelineClipDrag({
|
|
|
93
94
|
setRangeSelectionRef,
|
|
94
95
|
readZIndex,
|
|
95
96
|
onStackingPatches,
|
|
97
|
+
refreshAfterLaneMove,
|
|
96
98
|
}: UseTimelineClipDragInput) {
|
|
97
99
|
const updateElement = usePlayerStore((s) => s.updateElement);
|
|
98
100
|
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
|
|
@@ -213,6 +215,8 @@ export function useTimelineClipDrag({
|
|
|
213
215
|
readZIndexRef.current = readZIndex;
|
|
214
216
|
const onStackingPatchesRef = useRef(onStackingPatches);
|
|
215
217
|
onStackingPatchesRef.current = onStackingPatches;
|
|
218
|
+
const refreshAfterLaneMoveRef = useRef(refreshAfterLaneMove);
|
|
219
|
+
refreshAfterLaneMoveRef.current = refreshAfterLaneMove;
|
|
216
220
|
|
|
217
221
|
const clipDragScrollRaf = useRef(0);
|
|
218
222
|
const clipDragPointerRef = useRef<{
|
|
@@ -499,6 +503,7 @@ export function useTimelineClipDrag({
|
|
|
499
503
|
// deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync.
|
|
500
504
|
readZIndex: readZIndexRef.current,
|
|
501
505
|
onStackingPatches: onStackingPatchesRef.current,
|
|
506
|
+
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
|
502
507
|
});
|
|
503
508
|
};
|
|
504
509
|
|
|
@@ -12,6 +12,7 @@ interface UseTimelineEditPinningInput {
|
|
|
12
12
|
onFileDrop: TimelineDropCallbacks["onFileDrop"];
|
|
13
13
|
onAssetDrop: TimelineDropCallbacks["onAssetDrop"];
|
|
14
14
|
onBlockDrop: TimelineDropCallbacks["onBlockDrop"];
|
|
15
|
+
onCompositionDrop: TimelineDropCallbacks["onCompositionDrop"];
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
// Wrap every mutating timeline edit so the zoom pins to the current on-screen
|
|
@@ -29,6 +30,7 @@ export function useTimelineEditPinning({
|
|
|
29
30
|
onFileDrop,
|
|
30
31
|
onAssetDrop,
|
|
31
32
|
onBlockDrop,
|
|
33
|
+
onCompositionDrop,
|
|
32
34
|
}: UseTimelineEditPinningInput) {
|
|
33
35
|
const pinTimelineZoom = usePlayerStore((s) => s.pinTimelineZoom);
|
|
34
36
|
// Pin the timeline zoom to the current on-screen scale on the FIRST edit, so a
|
|
@@ -106,6 +108,15 @@ export function useTimelineEditPinning({
|
|
|
106
108
|
}),
|
|
107
109
|
[onBlockDrop, pinZoomBeforeEdit],
|
|
108
110
|
);
|
|
111
|
+
const pinnedOnCompositionDrop = useMemo(
|
|
112
|
+
() =>
|
|
113
|
+
onCompositionDrop &&
|
|
114
|
+
((...args: Parameters<typeof onCompositionDrop>) => {
|
|
115
|
+
pinZoomBeforeEdit();
|
|
116
|
+
return onCompositionDrop(...args);
|
|
117
|
+
}),
|
|
118
|
+
[onCompositionDrop, pinZoomBeforeEdit],
|
|
119
|
+
);
|
|
109
120
|
|
|
110
121
|
return {
|
|
111
122
|
pinZoomBeforeEdit,
|
|
@@ -117,5 +128,6 @@ export function useTimelineEditPinning({
|
|
|
117
128
|
pinnedOnFileDrop,
|
|
118
129
|
pinnedOnAssetDrop,
|
|
119
130
|
pinnedOnBlockDrop,
|
|
131
|
+
pinnedOnCompositionDrop,
|
|
120
132
|
};
|
|
121
133
|
}
|
|
@@ -48,9 +48,51 @@ describe("buildExpandedElements", () => {
|
|
|
48
48
|
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
|
|
49
49
|
const child = out.find((e) => e.domId === "stat-1")!;
|
|
50
50
|
expect(child.expandedParentStart).toBe(16);
|
|
51
|
+
expect(child.expandedHostKey).toBe("s3");
|
|
51
52
|
expect(child.sourceFile).toBe("stats.html");
|
|
52
53
|
});
|
|
53
54
|
|
|
55
|
+
it("keeps repeated same-source composition hosts as distinct move identities", () => {
|
|
56
|
+
const elements = [
|
|
57
|
+
el({
|
|
58
|
+
id: "host-a",
|
|
59
|
+
key: "index.html#host-a",
|
|
60
|
+
start: 0,
|
|
61
|
+
duration: 5,
|
|
62
|
+
compositionSrc: "scene.html",
|
|
63
|
+
}),
|
|
64
|
+
el({
|
|
65
|
+
id: "host-b",
|
|
66
|
+
key: "index.html#host-b",
|
|
67
|
+
start: 8,
|
|
68
|
+
duration: 5,
|
|
69
|
+
compositionSrc: "scene.html",
|
|
70
|
+
}),
|
|
71
|
+
];
|
|
72
|
+
const manifest = [
|
|
73
|
+
clip({ id: "host-a", start: 0, duration: 5, compositionSrc: "scene.html" }),
|
|
74
|
+
clip({ id: "child-a", start: 1, duration: 2 }),
|
|
75
|
+
clip({ id: "host-b", start: 8, duration: 5, compositionSrc: "scene.html" }),
|
|
76
|
+
clip({ id: "child-b", start: 9, duration: 2 }),
|
|
77
|
+
];
|
|
78
|
+
const parentMap = new Map([
|
|
79
|
+
["child-a", "host-a"],
|
|
80
|
+
["child-b", "host-b"],
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const childA = buildExpandedElements(elements, manifest, parentMap, "host-a", "host-a").find(
|
|
84
|
+
(element) => element.domId === "child-a",
|
|
85
|
+
);
|
|
86
|
+
const childB = buildExpandedElements(elements, manifest, parentMap, "host-b", "host-b").find(
|
|
87
|
+
(element) => element.domId === "child-b",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(childA?.sourceFile).toBe("scene.html");
|
|
91
|
+
expect(childB?.sourceFile).toBe("scene.html");
|
|
92
|
+
expect(childA?.expandedHostKey).toBe("index.html#host-a");
|
|
93
|
+
expect(childB?.expandedHostKey).toBe("index.html#host-b");
|
|
94
|
+
});
|
|
95
|
+
|
|
54
96
|
// fallow-ignore-next-line code-duplication
|
|
55
97
|
it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
|
|
56
98
|
// top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
|
|
@@ -133,6 +133,7 @@ function buildChildElements(
|
|
|
133
133
|
siblings: ClipManifestClip[],
|
|
134
134
|
display: DisplayBounds,
|
|
135
135
|
editBasis: { start: number; sourceFile: string | undefined },
|
|
136
|
+
expandedHostKey: string,
|
|
136
137
|
): TimelineElement[] {
|
|
137
138
|
const result: TimelineElement[] = [];
|
|
138
139
|
for (const child of siblings) {
|
|
@@ -182,6 +183,7 @@ function buildChildElements(
|
|
|
182
183
|
authoredTrack: base.authoredTrack,
|
|
183
184
|
stackingContextId: base.stackingContextId,
|
|
184
185
|
expandedParentStart: editBasis.start,
|
|
186
|
+
expandedHostKey,
|
|
185
187
|
domId,
|
|
186
188
|
selector,
|
|
187
189
|
sourceFile: editBasis.sourceFile,
|
|
@@ -260,6 +262,7 @@ export function buildExpandedElements(
|
|
|
260
262
|
track: topLevelElement.track,
|
|
261
263
|
},
|
|
262
264
|
editBasis,
|
|
265
|
+
parentKey,
|
|
263
266
|
);
|
|
264
267
|
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
|
|
265
268
|
|
|
@@ -110,7 +110,65 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
describe("parseTimelineFromDOM — canonical playback rate", () => {
|
|
114
|
+
it.each([
|
|
115
|
+
["10", 5],
|
|
116
|
+
["0.01", 0.1],
|
|
117
|
+
])("clamps authored rate %s to %s for trim and split math", (authored, expected) => {
|
|
118
|
+
const doc = makeDoc(`
|
|
119
|
+
<div data-composition-id="root">
|
|
120
|
+
<div id="nested" class="clip" data-composition-src="scene.html"
|
|
121
|
+
data-start="0" data-duration="5" data-playback-rate="${authored}"></div>
|
|
122
|
+
</div>
|
|
123
|
+
`);
|
|
124
|
+
|
|
125
|
+
const nested = parseTimelineFromDOM(doc, 10).find((entry) => entry.domId === "nested");
|
|
126
|
+
|
|
127
|
+
expect(nested?.playbackRate).toBe(expected);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
113
131
|
describe("createTimelineElementFromManifestClip — source-scoped selector identity", () => {
|
|
132
|
+
it("preserves composition kind and source timing on first translation", () => {
|
|
133
|
+
const doc = makeDoc(`
|
|
134
|
+
<div data-composition-id="root" data-composition-file="index.html">
|
|
135
|
+
<div id="host" data-composition-id="scene" data-composition-src="scene.html"
|
|
136
|
+
data-playback-start="1.5" data-playback-rate="2"></div>
|
|
137
|
+
</div>
|
|
138
|
+
`);
|
|
139
|
+
const host = doc.getElementById("host");
|
|
140
|
+
|
|
141
|
+
const element = createTimelineElementFromManifestClip({
|
|
142
|
+
clip: {
|
|
143
|
+
id: "host",
|
|
144
|
+
label: "Scene",
|
|
145
|
+
kind: "composition",
|
|
146
|
+
tagName: "div",
|
|
147
|
+
start: 2,
|
|
148
|
+
duration: 4,
|
|
149
|
+
track: 0,
|
|
150
|
+
compositionId: "scene",
|
|
151
|
+
parentCompositionId: "root",
|
|
152
|
+
compositionSrc: "scene.html",
|
|
153
|
+
playbackStart: 1.5,
|
|
154
|
+
playbackRate: 2,
|
|
155
|
+
assetUrl: null,
|
|
156
|
+
},
|
|
157
|
+
fallbackIndex: 0,
|
|
158
|
+
doc,
|
|
159
|
+
hostEl: host,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(element).toMatchObject({
|
|
163
|
+
kind: "composition",
|
|
164
|
+
compositionSrc: "scene.html",
|
|
165
|
+
playbackStart: 1.5,
|
|
166
|
+
playbackStartAttr: "playback-start",
|
|
167
|
+
playbackRate: 2,
|
|
168
|
+
domId: "host",
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
114
172
|
it("ignores an index.html duplicate when indexing a scene.html selector", () => {
|
|
115
173
|
const doc = makeDoc(`
|
|
116
174
|
<div data-composition-id="root" data-composition-file="index.html">
|
|
@@ -111,6 +111,7 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
111
111
|
id: identity.id,
|
|
112
112
|
label,
|
|
113
113
|
key: identity.key,
|
|
114
|
+
kind: clip.kind,
|
|
114
115
|
tag: resolveClipTag(clip),
|
|
115
116
|
start: clip.start,
|
|
116
117
|
duration: clip.duration,
|
|
@@ -129,6 +130,8 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
129
130
|
selector,
|
|
130
131
|
selectorIndex,
|
|
131
132
|
sourceFile,
|
|
133
|
+
playbackStart: clip.playbackStart,
|
|
134
|
+
playbackRate: clip.playbackRate,
|
|
132
135
|
};
|
|
133
136
|
|
|
134
137
|
if (hostEl) {
|
|
@@ -140,6 +143,8 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
140
143
|
}
|
|
141
144
|
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
142
145
|
if (clip.kind === "composition" && clip.compositionId) {
|
|
146
|
+
entry.playbackStart ??= 0;
|
|
147
|
+
entry.playbackRate ??= 1;
|
|
143
148
|
let resolvedSrc = clip.compositionSrc;
|
|
144
149
|
if (!resolvedSrc) {
|
|
145
150
|
hostEl =
|
|
@@ -293,6 +298,14 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
293
298
|
id: identity.id,
|
|
294
299
|
label,
|
|
295
300
|
key: identity.key,
|
|
301
|
+
kind:
|
|
302
|
+
compId && compId !== rootComp?.getAttribute("data-composition-id")
|
|
303
|
+
? "composition"
|
|
304
|
+
: tagLower === "video" || tagLower === "audio"
|
|
305
|
+
? tagLower
|
|
306
|
+
: tagLower === "img"
|
|
307
|
+
? "image"
|
|
308
|
+
: "element",
|
|
296
309
|
tag: tagLower,
|
|
297
310
|
start,
|
|
298
311
|
duration: dur,
|
|
@@ -308,13 +321,13 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
308
321
|
};
|
|
309
322
|
|
|
310
323
|
const mediaEl = resolveMediaElement(el);
|
|
324
|
+
applyMediaMetadataFromElement(entry, el);
|
|
311
325
|
if (mediaEl) {
|
|
312
326
|
if (mediaEl.tagName === "IMG") {
|
|
313
327
|
entry.tag = "img";
|
|
314
328
|
}
|
|
315
329
|
const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
|
|
316
330
|
if (vol) entry.volume = parseFloat(vol);
|
|
317
|
-
applyMediaMetadataFromElement(entry, el);
|
|
318
331
|
// Override AFTER the helper (which sets the raw relative attribute) so the
|
|
319
332
|
// resolved absolute URL wins — the Studio can then fetch the asset
|
|
320
333
|
// regardless of whether the attribute value was relative or absolute.
|
|
@@ -345,6 +358,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
345
358
|
entry.tag = "video";
|
|
346
359
|
}
|
|
347
360
|
}
|
|
361
|
+
if (entry.kind === "composition") {
|
|
362
|
+
entry.playbackStart ??= 0;
|
|
363
|
+
entry.playbackRate ??= 1;
|
|
364
|
+
}
|
|
348
365
|
|
|
349
366
|
els.push(entry);
|
|
350
367
|
});
|