@hyperframes/studio 0.8.111 → 0.8.113
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-DvJeHBHi.js → hyperframes-player-DzJbtQVO.js} +1 -1
- package/dist/assets/{index-SFktf7O1.js → index-DRhF3v4Z.js} +194 -194
- package/dist/assets/{index-C7apTvpr.css → index-DbfKVTId.css} +1 -1
- package/dist/assets/{index--G6c4Q_x.js → index-DgwDQK5G.js} +1 -1
- package/dist/assets/{index-Bt4vDkfw.js → index-n5FUn1xv.js} +1 -1
- package/dist/{chunk-PQFEBX32.js → chunk-BIUXP3PG.js} +2 -2
- package/dist/{chunk-6LUNLTHR.js → chunk-K47E2ZJA.js} +13 -8
- package/dist/chunk-K47E2ZJA.js.map +1 -0
- package/dist/{chunk-QUXZVHOJ.js → chunk-VWT7STY3.js} +2 -2
- package/dist/{domEditingLayers-JQPQGGT7.js → domEditingLayers-V67MS4WP.js} +3 -3
- package/dist/index.d.ts +80 -46
- package/dist/index.html +2 -2
- package/dist/index.js +7294 -6844
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +10 -8
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
- package/src/hooks/timelineEditingHelpers.ts +7 -0
- package/src/hooks/timelineMoveAdapter.test.ts +12 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -4
- package/src/hooks/useTimelineGroupEditing.test.tsx +77 -0
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +7 -0
- package/src/main.tsx +4 -0
- package/src/player/components/ClipBadges.tsx +5 -5
- package/src/player/components/Timeline.ghostLane.test.tsx +8 -61
- package/src/player/components/Timeline.trackPadding.test.tsx +11 -56
- package/src/player/components/TimelineCanvas.tsx +6 -2
- package/src/player/components/TimelineClip.filmstrip.test.tsx +156 -0
- package/src/player/components/TimelineClip.test.tsx +7 -7
- package/src/player/components/TimelineClip.tsx +23 -7
- package/src/player/components/TimelineClipFades.tsx +41 -43
- package/src/player/components/TimelineGestureOverlay.test.tsx +41 -0
- package/src/player/components/TimelineGestureOverlay.tsx +59 -21
- package/src/player/components/TimelineLanes.tsx +18 -16
- package/src/player/components/timelineCallbacks.ts +6 -4
- package/src/player/components/timelineClipChildren.tsx +7 -13
- package/src/player/components/timelineClipDragCommit.test.ts +51 -0
- package/src/player/components/timelineClipDragCommit.ts +63 -21
- package/src/player/components/timelineClipDragGestureLifecycle.ts +18 -2
- package/src/player/components/timelineClipDragPreview.test.ts +47 -31
- package/src/player/components/timelineClipDragPreview.ts +81 -17
- package/src/player/components/timelineClipDragTypes.ts +3 -3
- package/src/player/components/timelineClipFadeGeometry.test.ts +29 -0
- package/src/player/components/timelineClipFadeGeometry.ts +74 -0
- package/src/player/components/timelineCollision.ts +51 -11
- package/src/player/components/timelineDragLanding.ts +20 -6
- package/src/player/components/timelineLaneProps.ts +3 -8
- package/src/player/components/timelineMotionStyles.test.ts +6 -13
- package/src/player/components/timelineMountFixtures.tsx +65 -0
- package/src/player/components/timelineOptimisticRevision.ts +15 -4
- package/src/player/components/timelineTheme.ts +2 -0
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackInsertLayout.test.ts +249 -0
- package/src/player/components/timelineTrackInsertLayout.ts +71 -0
- package/src/player/components/timelineZMirror.ts +5 -11
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +163 -0
- package/src/player/components/useTimelineClipDrag.ts +116 -12
- package/src/player/components/useTimelineDisplayLayout.test.tsx +59 -0
- package/src/player/components/useTimelineKeyboardActor.ts +86 -7
- package/src/player/components/useTimelineProviderState.tsx +3 -1
- package/src/player/components/useTimelineRowVirtualization.test.tsx +56 -0
- package/src/player/components/useTimelineRowVirtualization.ts +3 -1
- package/src/player/components/useTimelineTrackDerivations.test.ts +14 -1
- package/src/player/components/useTimelineTrackDerivations.ts +32 -18
- package/src/player/components/useTimelineTrackLayout.ts +43 -14
- package/src/player/hooks/timelinePlayerSync.test.ts +7 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/lib/automationStoreSync.test.ts +15 -0
- package/src/player/lib/automationStoreSync.ts +13 -5
- package/src/player/lib/storedVolume.ts +7 -0
- package/src/player/lib/thumbnailPageLifecycle.test.ts +123 -0
- package/src/player/lib/thumbnailPageLifecycle.ts +27 -0
- package/src/player/lib/thumbnailScheduler.ts +20 -2
- package/src/player/lib/timelineElementHelpers.ts +2 -8
- package/src/styles/components.css +71 -19
- package/src/styles/theme.css +4 -1
- package/dist/chunk-6LUNLTHR.js.map +0 -1
- /package/dist/{chunk-PQFEBX32.js.map → chunk-BIUXP3PG.js.map} +0 -0
- /package/dist/{chunk-QUXZVHOJ.js.map → chunk-VWT7STY3.js.map} +0 -0
- /package/dist/{domEditingLayers-JQPQGGT7.js.map → domEditingLayers-V67MS4WP.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.113",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/parsers": "0.8.
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.113",
|
|
64
|
+
"@hyperframes/player": "0.8.113",
|
|
65
|
+
"@hyperframes/sdk": "0.8.113",
|
|
66
|
+
"@hyperframes/parsers": "0.8.113",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.113",
|
|
68
|
+
"@hyperframes/shader-transitions": "0.8.113"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.113"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -94,7 +94,9 @@
|
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
|
95
95
|
"test": "vitest run",
|
|
96
96
|
"test:edit-accuracy": "bun tests/e2e/edit-accuracy/run.mjs",
|
|
97
|
+
"test:filmstrip-controls": "node tests/e2e/filmstrip-controls.mjs",
|
|
97
98
|
"test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs",
|
|
99
|
+
"test:thumbnail-save": "node tests/e2e/thumbnail-save.mjs",
|
|
98
100
|
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
|
|
99
101
|
"test:watch": "vitest",
|
|
100
102
|
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
|
|
@@ -2,13 +2,13 @@ import { useCallback, useRef } from "react";
|
|
|
2
2
|
import { usePlayerStore } from "../../player";
|
|
3
3
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
4
4
|
import {
|
|
5
|
-
displayTrackOrder,
|
|
6
5
|
resolveRepositionLaneMove,
|
|
7
6
|
resolveZMirrorLaneMove,
|
|
8
7
|
type ZMirrorAction,
|
|
9
8
|
type ZMirrorLaneMove,
|
|
10
9
|
} from "../../player/components/timelineZMirror";
|
|
11
10
|
import type { TimelineElement } from "../../player/store/playerStore";
|
|
11
|
+
import { timelineTrackOrder } from "../../player/components/timelineTrackDisplay";
|
|
12
12
|
import { commitZMirrorLaneMove } from "../../player/components/timelineClipDragCommit";
|
|
13
13
|
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
|
14
14
|
import { buildStableSelector, getSelectorIndex } from "../editor/domEditingDom";
|
|
@@ -156,7 +156,7 @@ function useMirrorLaneMoveCommit(): (
|
|
|
156
156
|
move,
|
|
157
157
|
{
|
|
158
158
|
elements: els,
|
|
159
|
-
trackOrder:
|
|
159
|
+
trackOrder: timelineTrackOrder(els),
|
|
160
160
|
updateElement: (key, updates) => usePlayerStore.getState().updateElement(key, updates),
|
|
161
161
|
onMoveElements,
|
|
162
162
|
// NO readZIndex / onStackingPatches: see the hook doc — the lane→z
|
|
@@ -238,6 +238,7 @@ export function buildTimelineMoveTimingPatch(
|
|
|
238
238
|
start: number,
|
|
239
239
|
duration: number,
|
|
240
240
|
track?: number,
|
|
241
|
+
audioGroup?: null,
|
|
241
242
|
): string {
|
|
242
243
|
if (!Number.isFinite(start) || !Number.isFinite(duration)) {
|
|
243
244
|
console.warn(
|
|
@@ -257,6 +258,12 @@ export function buildTimelineMoveTimingPatch(
|
|
|
257
258
|
value: formatTimelineAttributeNumber(track),
|
|
258
259
|
});
|
|
259
260
|
}
|
|
261
|
+
if (audioGroup === null)
|
|
262
|
+
patched = applyPatchByTarget(patched, target, {
|
|
263
|
+
type: "attribute",
|
|
264
|
+
property: "data-audio-group",
|
|
265
|
+
value: null,
|
|
266
|
+
});
|
|
260
267
|
// Content-driven duration: sync data-duration to the furthest clip end read
|
|
261
268
|
// from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved
|
|
262
269
|
// past the end and shrinks if the furthest clip moved left. Measured from the
|
|
@@ -68,6 +68,18 @@ describe("persistTimelineMoveEditsAtomically", () => {
|
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
it("carries explicit detachment through the atomic move", async () => {
|
|
72
|
+
const edit = {
|
|
73
|
+
element: { ...element("voice", 1), audioGroup: "group" },
|
|
74
|
+
updates: { start: 1, track: 0, audioGroup: null },
|
|
75
|
+
};
|
|
76
|
+
const handle = await runMove([edit], "move", "track-insert");
|
|
77
|
+
expect(handle).toHaveBeenCalledWith(
|
|
78
|
+
[{ element: edit.element, start: 1, track: 0, audioGroup: null }],
|
|
79
|
+
{ coalesceKey: "move" },
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
71
83
|
it("rejects without retrying individual members when the atomic batch fails", async () => {
|
|
72
84
|
const failure = new Error("batch failed");
|
|
73
85
|
const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure);
|
|
@@ -5,9 +5,13 @@ import type {
|
|
|
5
5
|
TimelineGroupMoveChange,
|
|
6
6
|
} from "./useTimelineGroupEditing";
|
|
7
7
|
|
|
8
|
-
export
|
|
8
|
+
export type TimelineAtomicMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
|
9
|
+
audioGroup?: null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface TimelineAtomicMoveEdit {
|
|
9
13
|
element: TimelineElement;
|
|
10
|
-
updates:
|
|
14
|
+
updates: TimelineAtomicMoveUpdates;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
interface AtomicMoveDeps {
|
|
@@ -20,14 +24,14 @@ interface AtomicMoveDeps {
|
|
|
20
24
|
export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert";
|
|
21
25
|
|
|
22
26
|
export type TimelineMoveEditsHandler = (
|
|
23
|
-
edits:
|
|
27
|
+
edits: TimelineAtomicMoveEdit[],
|
|
24
28
|
coalesceKey?: string,
|
|
25
29
|
operation?: TimelineMoveOperation,
|
|
26
30
|
coalesceMs?: number,
|
|
27
31
|
) => Promise<void>;
|
|
28
32
|
|
|
29
33
|
export function persistTimelineMoveEditsAtomically(
|
|
30
|
-
edits:
|
|
34
|
+
edits: TimelineAtomicMoveEdit[],
|
|
31
35
|
coalesceKey: string | undefined,
|
|
32
36
|
operation: TimelineMoveOperation,
|
|
33
37
|
deps: AtomicMoveDeps,
|
|
@@ -37,6 +41,7 @@ export function persistTimelineMoveEditsAtomically(
|
|
|
37
41
|
edits.map(({ element, updates }) => ({
|
|
38
42
|
element,
|
|
39
43
|
start: updates.start,
|
|
44
|
+
audioGroup: updates.audioGroup,
|
|
40
45
|
// Stable track lanes: a lane is the authored data-track-index, so every
|
|
41
46
|
// vertical gesture (lane-reorder AND track-insert) must persist the track;
|
|
42
47
|
// z is paint order only and is synced separately. Plain horizontal moves
|
|
@@ -4,6 +4,7 @@ import { act } from "react";
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { Root } from "react-dom/client";
|
|
6
6
|
import type { TimelineElement } from "../player";
|
|
7
|
+
import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
|
|
7
8
|
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
|
8
9
|
import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
|
|
9
10
|
|
|
@@ -72,3 +73,79 @@ describe("useTimelineGroupEditing: handleTimelineGroupMove suppressFailureToast"
|
|
|
72
73
|
expect(showToast).toHaveBeenCalledTimes(1);
|
|
73
74
|
});
|
|
74
75
|
});
|
|
76
|
+
|
|
77
|
+
it.each([false, true])(
|
|
78
|
+
"keeps detachment and the lane atomic, including a refused history write ($0)",
|
|
79
|
+
async (refused) => {
|
|
80
|
+
const before =
|
|
81
|
+
'<main data-composition-id="root" data-duration="4"><audio id="voice" data-start="0" data-duration="2" data-track-index="1" data-audio-group="group"></audio></main>';
|
|
82
|
+
let disk = before;
|
|
83
|
+
const failure = new Error("history refused");
|
|
84
|
+
const recordEdit = refused ? vi.fn().mockRejectedValue(failure) : vi.fn();
|
|
85
|
+
const iframe = document.createElement("iframe");
|
|
86
|
+
document.body.append(iframe);
|
|
87
|
+
iframe.contentDocument!.body.innerHTML = before;
|
|
88
|
+
const writeProjectFile = vi.fn(async (_path: string, content: string) => {
|
|
89
|
+
disk = content;
|
|
90
|
+
});
|
|
91
|
+
vi.stubGlobal(
|
|
92
|
+
"fetch",
|
|
93
|
+
vi.fn(
|
|
94
|
+
async () =>
|
|
95
|
+
new Response(JSON.stringify({ content: disk }), {
|
|
96
|
+
headers: { "content-type": "application/json" },
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
let hook: ReturnType<typeof useTimelineGroupEditing>;
|
|
101
|
+
function Harness() {
|
|
102
|
+
hook = useTimelineGroupEditing({
|
|
103
|
+
activeCompPath: "index.html",
|
|
104
|
+
editQueueRef: { current: Promise.resolve() },
|
|
105
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
106
|
+
previewIframeRef: { current: iframe },
|
|
107
|
+
projectIdRef: { current: "fixture" },
|
|
108
|
+
recordEdit,
|
|
109
|
+
writeProjectFile,
|
|
110
|
+
reloadPreview: vi.fn(),
|
|
111
|
+
showToast: vi.fn(),
|
|
112
|
+
});
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const root = mountReactHarness(<Harness />);
|
|
116
|
+
try {
|
|
117
|
+
await act(async () => {
|
|
118
|
+
const moved = persistTimelineMoveEditsAtomically(
|
|
119
|
+
[
|
|
120
|
+
{
|
|
121
|
+
element: { ...el("voice", 0, 2, 1), tag: "audio", audioGroup: "group" },
|
|
122
|
+
updates: { start: 0, track: 0, audioGroup: null },
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
"insert",
|
|
126
|
+
"track-insert",
|
|
127
|
+
{ handleTimelineGroupMove: hook!.handleTimelineGroupMove },
|
|
128
|
+
);
|
|
129
|
+
if (refused) await expect(moved).rejects.toBe(failure);
|
|
130
|
+
else await moved;
|
|
131
|
+
});
|
|
132
|
+
expect(
|
|
133
|
+
iframe.contentDocument!.getElementById("voice")?.getAttribute("data-audio-group"),
|
|
134
|
+
).toBe("group");
|
|
135
|
+
if (refused) {
|
|
136
|
+
expect(disk).toBe(before);
|
|
137
|
+
expect(writeProjectFile).toHaveBeenCalledTimes(2);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
expect(writeProjectFile).toHaveBeenCalledOnce();
|
|
141
|
+
expect(recordEdit).toHaveBeenCalledOnce();
|
|
142
|
+
expect(disk).not.toContain("data-audio-group");
|
|
143
|
+
expect(disk).toContain('data-track-index="0"');
|
|
144
|
+
expect(recordEdit.mock.calls[0][0].files["index.html"]).toEqual({ before, after: disk });
|
|
145
|
+
} finally {
|
|
146
|
+
act(() => root.unmount());
|
|
147
|
+
iframe.remove();
|
|
148
|
+
vi.unstubAllGlobals();
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
);
|
|
@@ -37,6 +37,7 @@ export interface TimelineGroupMoveChange {
|
|
|
37
37
|
element: TimelineElement;
|
|
38
38
|
start: number;
|
|
39
39
|
track?: number;
|
|
40
|
+
audioGroup?: null;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export interface TimelineGroupResizeChange {
|
|
@@ -296,7 +297,9 @@ export function useTimelineGroupEditing({
|
|
|
296
297
|
sdkChanges: toSdkTimingChanges(changes, (change) => ({
|
|
297
298
|
start: toAuthoredStart(change.element, change.start),
|
|
298
299
|
})),
|
|
299
|
-
eligible: changes.every(
|
|
300
|
+
eligible: changes.every(
|
|
301
|
+
(change) => change.track == null && change.audioGroup === undefined,
|
|
302
|
+
),
|
|
300
303
|
needsExtension,
|
|
301
304
|
label,
|
|
302
305
|
coalesceKey,
|
|
@@ -315,6 +318,7 @@ export function useTimelineGroupEditing({
|
|
|
315
318
|
toAuthoredStart(change.element, change.start),
|
|
316
319
|
change.element.duration,
|
|
317
320
|
change.track,
|
|
321
|
+
change.audioGroup,
|
|
318
322
|
),
|
|
319
323
|
})),
|
|
320
324
|
coalesceKey,
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,13 @@ export { TimelineProvider, useTimelineContext } from "./player/components/Timeli
|
|
|
62
62
|
export type { TimelineTheme } from "./player/components/timelineTheme";
|
|
63
63
|
export { TRACK_H } from "./player/components/timelineLayout";
|
|
64
64
|
export type { TimelineTrackPadding } from "./player/components/timelineLayout";
|
|
65
|
+
export { displayTrackOrder } from "./player/components/useTimelineTrackDerivations";
|
|
66
|
+
export { fadeHandleBoxes } from "./player/components/timelineClipFadeGeometry";
|
|
67
|
+
export type {
|
|
68
|
+
FadeEdge,
|
|
69
|
+
FadeHandleBox,
|
|
70
|
+
FadeHandleClipBox,
|
|
71
|
+
} from "./player/components/timelineClipFadeGeometry";
|
|
65
72
|
|
|
66
73
|
// Clip content thumbnails: used by a host rendering its own timeline lane.
|
|
67
74
|
export { AudioWaveform } from "./player/components/AudioWaveform";
|
package/src/main.tsx
CHANGED
|
@@ -7,8 +7,12 @@ import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
|
|
|
7
7
|
import { readIconTokens } from "./styles/iconTokens";
|
|
8
8
|
import { trackStudioEvent } from "./utils/studioTelemetry";
|
|
9
9
|
import { prefetchPreviewForHash } from "./utils/previewPrefetch";
|
|
10
|
+
import { bindThumbnailPageLifecycle } from "./player/lib/thumbnailPageLifecycle";
|
|
10
11
|
import "./styles/studio.css";
|
|
11
12
|
|
|
13
|
+
const unbindThumbnailPageLifecycle = bindThumbnailPageLifecycle(window, document);
|
|
14
|
+
import.meta.hot?.dispose(unbindThumbnailPageLifecycle);
|
|
15
|
+
|
|
12
16
|
prefetchPreviewForHash(window.location.hash);
|
|
13
17
|
window.addEventListener("hashchange", () => prefetchPreviewForHash(window.location.hash));
|
|
14
18
|
trackStudioEvent("session_start");
|
|
@@ -24,7 +24,7 @@ function SpeakerGlyph({ muted }: { muted: boolean }) {
|
|
|
24
24
|
const BADGE_CLASS =
|
|
25
25
|
"timeline-clip__badge inline-flex items-center gap-0.5 rounded-[3px] border bg-black/55 px-1 text-[9px] leading-[14px] whitespace-nowrap";
|
|
26
26
|
const FX_ON = "border-white/40 text-white font-semibold";
|
|
27
|
-
const FX_OFF = "border-white/10 text-white/
|
|
27
|
+
const FX_OFF = "border-white/10 text-white/60";
|
|
28
28
|
|
|
29
29
|
/** Premiere's fx badge — grey with no effects, white with any; hover lists them, click opens the clip menu. */
|
|
30
30
|
export const ClipBadges = memo(function ClipBadges({
|
|
@@ -44,21 +44,21 @@ export const ClipBadges = memo(function ClipBadges({
|
|
|
44
44
|
};
|
|
45
45
|
return (
|
|
46
46
|
<span
|
|
47
|
-
className="pointer-events-none absolute right-1.5 top-0.5 z-[
|
|
47
|
+
className="pointer-events-none absolute right-1.5 top-0.5 z-[31] flex max-w-[calc(100%-12px)] gap-1 overflow-hidden"
|
|
48
48
|
data-testid="clip-badges"
|
|
49
49
|
>
|
|
50
50
|
{volume && (
|
|
51
51
|
<span
|
|
52
|
-
className={`${BADGE_CLASS} border-white/20 text-white/90`}
|
|
52
|
+
className={`${BADGE_CLASS} min-w-0 overflow-hidden border-white/20 text-white/90`}
|
|
53
53
|
title={volume}
|
|
54
54
|
data-badge="volume"
|
|
55
55
|
>
|
|
56
56
|
<SpeakerGlyph muted={volume === "Muted"} />
|
|
57
|
-
{volume === "Muted" ? null : <span>{volume}</span>}
|
|
57
|
+
{volume === "Muted" ? null : <span className="truncate">{volume}</span>}
|
|
58
58
|
</span>
|
|
59
59
|
)}
|
|
60
60
|
<span
|
|
61
|
-
className={`${BADGE_CLASS} pointer-events-auto cursor-pointer ${hasEffects ? FX_ON : FX_OFF}`}
|
|
61
|
+
className={`${BADGE_CLASS} pointer-events-auto shrink-0 cursor-pointer ${hasEffects ? FX_ON : FX_OFF}`}
|
|
62
62
|
title={hasEffects ? effects.join(" · ") : "No effects"}
|
|
63
63
|
data-badge="fx"
|
|
64
64
|
data-fx-active={hasEffects ? "true" : "false"}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { act } from "react";
|
|
5
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
mountThreeTrackTimeline as mountThreeTracks,
|
|
7
|
+
dragTimelineFixtureAsset,
|
|
8
|
+
pointerTimelineFixture as pointer,
|
|
9
|
+
} from "./timelineMountFixtures";
|
|
7
10
|
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
|
8
|
-
import { usePlayerStore } from "../store/playerStore";
|
|
9
11
|
import { CLIP_Y, TRACK_H, getTimelineRowTop } from "./timelineLayout";
|
|
10
|
-
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
11
|
-
import type { TimelineProps } from "./TimelineTypes";
|
|
12
12
|
|
|
13
13
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
14
|
|
|
@@ -21,32 +21,6 @@ afterEach(() => {
|
|
|
21
21
|
const GHOST_MID = getTimelineRowTop(3) + TRACK_H / 2;
|
|
22
22
|
const ROW1_MID = getTimelineRowTop(1) + TRACK_H / 2;
|
|
23
23
|
|
|
24
|
-
async function mountThreeTracks(props: TimelineProps = {}) {
|
|
25
|
-
usePlayerStore.setState({
|
|
26
|
-
duration: 10,
|
|
27
|
-
currentTime: 0,
|
|
28
|
-
timelineReady: true,
|
|
29
|
-
gsapAnimations: new Map(),
|
|
30
|
-
selectedElementId: null,
|
|
31
|
-
selectedElementIds: new Set(),
|
|
32
|
-
elements: [0, 1, 2].map((track) => ({
|
|
33
|
-
id: `c${track}`,
|
|
34
|
-
key: `c${track}`,
|
|
35
|
-
domId: `c${track}`,
|
|
36
|
-
tag: "div",
|
|
37
|
-
start: 0,
|
|
38
|
-
duration: 2,
|
|
39
|
-
track,
|
|
40
|
-
})),
|
|
41
|
-
});
|
|
42
|
-
const host = document.createElement("div");
|
|
43
|
-
document.body.append(host);
|
|
44
|
-
const root = createRoot(host);
|
|
45
|
-
await act(async () => root.render(<Timeline {...props} />));
|
|
46
|
-
const ghost = () => host.querySelector<HTMLElement>('[data-testid="timeline-ghost-lane"]');
|
|
47
|
-
return { host, root, ghost };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
24
|
describe("Timeline ghost lane", () => {
|
|
51
25
|
it("draws one empty track below the last with a drop hint", async () => {
|
|
52
26
|
const { root, ghost } = await mountThreeTracks({ onAssetDrop: vi.fn() });
|
|
@@ -70,22 +44,8 @@ describe("Timeline ghost lane", () => {
|
|
|
70
44
|
const onAssetDrop = vi.fn();
|
|
71
45
|
const { host, root, ghost } = await mountThreeTracks({ onAssetDrop });
|
|
72
46
|
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
73
|
-
const drag = (type: string, clientY: number) =>
|
|
74
|
-
|
|
75
|
-
Object.defineProperties(event, {
|
|
76
|
-
clientX: { value: 400 },
|
|
77
|
-
clientY: { value: clientY },
|
|
78
|
-
dataTransfer: {
|
|
79
|
-
value: {
|
|
80
|
-
types: [TIMELINE_ASSET_MIME],
|
|
81
|
-
files: [],
|
|
82
|
-
dropEffect: "none",
|
|
83
|
-
getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
});
|
|
87
|
-
act(() => void viewport.dispatchEvent(event));
|
|
88
|
-
};
|
|
47
|
+
const drag = (type: string, clientY: number) =>
|
|
48
|
+
dragTimelineFixtureAsset(viewport, type, clientY);
|
|
89
49
|
drag("dragover", ROW1_MID);
|
|
90
50
|
expect(ghost()?.dataset.active).toBeUndefined();
|
|
91
51
|
drag("dragover", GHOST_MID);
|
|
@@ -100,19 +60,6 @@ describe("Timeline ghost lane", () => {
|
|
|
100
60
|
onMoveElement: vi.fn(),
|
|
101
61
|
onResizeElement: vi.fn(),
|
|
102
62
|
});
|
|
103
|
-
const pointer = (target: EventTarget, type: string, clientY: number) =>
|
|
104
|
-
act(
|
|
105
|
-
() =>
|
|
106
|
-
void target.dispatchEvent(
|
|
107
|
-
new PointerEvent(type, {
|
|
108
|
-
bubbles: true,
|
|
109
|
-
button: 0,
|
|
110
|
-
pointerId: 1,
|
|
111
|
-
clientX: 400,
|
|
112
|
-
clientY,
|
|
113
|
-
}),
|
|
114
|
-
),
|
|
115
|
-
);
|
|
116
63
|
const clipMid = getTimelineRowTop(track) + TRACK_H / 2;
|
|
117
64
|
pointer(host.querySelector(`[data-clip][data-el-id="c${track}"]`)!, "pointerdown", clipMid);
|
|
118
65
|
pointer(window, "pointermove", ROW1_MID);
|
|
@@ -7,7 +7,11 @@ import { Timeline } from "./Timeline";
|
|
|
7
7
|
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
|
8
8
|
import { usePlayerStore } from "../store/playerStore";
|
|
9
9
|
import { CLIP_Y, RULER_H, TRACK_H, TRACKS_BOTTOM_PAD, TRACKS_TOP_PAD } from "./timelineLayout";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
mountThreeTrackTimeline,
|
|
12
|
+
dragTimelineFixtureAsset,
|
|
13
|
+
pointerTimelineFixture as pointer,
|
|
14
|
+
} from "./timelineMountFixtures";
|
|
11
15
|
import type { TimelineProps } from "./TimelineTypes";
|
|
12
16
|
|
|
13
17
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -59,29 +63,7 @@ describe("Timeline trackPadding", () => {
|
|
|
59
63
|
const hostPads = { top: 0, bottom: TRACK_H };
|
|
60
64
|
|
|
61
65
|
async function mountThreeTracks(props: TimelineProps = {}) {
|
|
62
|
-
|
|
63
|
-
duration: 10,
|
|
64
|
-
currentTime: 0,
|
|
65
|
-
timelineReady: true,
|
|
66
|
-
gsapAnimations: new Map(),
|
|
67
|
-
selectedElementId: null,
|
|
68
|
-
selectedElementIds: new Set(),
|
|
69
|
-
elements: [0, 1, 2].map((track) => ({
|
|
70
|
-
id: `c${track}`,
|
|
71
|
-
key: `c${track}`,
|
|
72
|
-
domId: `c${track}`,
|
|
73
|
-
tag: "div",
|
|
74
|
-
start: 0,
|
|
75
|
-
duration: 2,
|
|
76
|
-
track,
|
|
77
|
-
})),
|
|
78
|
-
});
|
|
79
|
-
const host = document.createElement("div");
|
|
80
|
-
document.body.append(host);
|
|
81
|
-
const root = createRoot(host);
|
|
82
|
-
await act(async () => root.render(<Timeline trackPadding={hostPads} {...props} />));
|
|
83
|
-
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
84
|
-
return { host, root, viewport };
|
|
66
|
+
return mountThreeTrackTimeline({ trackPadding: hostPads, ...props });
|
|
85
67
|
}
|
|
86
68
|
|
|
87
69
|
const top = (el: Element | null | undefined) => (el as HTMLElement | null)?.style.top;
|
|
@@ -94,22 +76,8 @@ describe("Timeline trackPadding", () => {
|
|
|
94
76
|
`${RULER_H + TRACK_H + CLIP_Y}px`,
|
|
95
77
|
);
|
|
96
78
|
|
|
97
|
-
const drag = (type: string, clientY: number) =>
|
|
98
|
-
|
|
99
|
-
Object.defineProperties(event, {
|
|
100
|
-
clientX: { value: 400 },
|
|
101
|
-
clientY: { value: clientY },
|
|
102
|
-
dataTransfer: {
|
|
103
|
-
value: {
|
|
104
|
-
types: [TIMELINE_ASSET_MIME],
|
|
105
|
-
files: [],
|
|
106
|
-
dropEffect: "none",
|
|
107
|
-
getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
});
|
|
111
|
-
act(() => void viewport.dispatchEvent(event));
|
|
112
|
-
};
|
|
79
|
+
const drag = (type: string, clientY: number) =>
|
|
80
|
+
dragTimelineFixtureAsset(viewport, type, clientY);
|
|
113
81
|
drag("dragover", ROW1_MID);
|
|
114
82
|
expect(top(host.querySelector('[data-testid="timeline-drop-preview"]'))).toBe(
|
|
115
83
|
`${RULER_H + TRACK_H + CLIP_Y}px`,
|
|
@@ -128,19 +96,6 @@ describe("Timeline trackPadding", () => {
|
|
|
128
96
|
onMoveElement: vi.fn(),
|
|
129
97
|
onResizeElement: vi.fn(),
|
|
130
98
|
});
|
|
131
|
-
const pointer = (target: EventTarget, type: string, clientY: number) =>
|
|
132
|
-
act(
|
|
133
|
-
() =>
|
|
134
|
-
void target.dispatchEvent(
|
|
135
|
-
new PointerEvent(type, {
|
|
136
|
-
bubbles: true,
|
|
137
|
-
button: 0,
|
|
138
|
-
pointerId: 1,
|
|
139
|
-
clientX: 400,
|
|
140
|
-
clientY,
|
|
141
|
-
}),
|
|
142
|
-
),
|
|
143
|
-
);
|
|
144
99
|
const clip = host.querySelector('[data-clip][data-el-id="c0"]')!;
|
|
145
100
|
pointer(clip, "pointerdown", RULER_H + TRACK_H / 2);
|
|
146
101
|
pointer(window, "pointermove", ROW1_MID);
|
|
@@ -149,9 +104,9 @@ describe("Timeline trackPadding", () => {
|
|
|
149
104
|
).find((el) => el.style.zIndex === "30" && !el.dataset.testid);
|
|
150
105
|
expect(top(ghost)).toBe(`${RULER_H + TRACK_H + CLIP_Y}px`);
|
|
151
106
|
pointer(window, "pointermove", RULER_H + 3 * TRACK_H + TRACK_H / 2);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
);
|
|
107
|
+
const lane = host.querySelector<HTMLElement>("[data-timeline-new-track-lane]");
|
|
108
|
+
expect(top(lane)).toBe(`${RULER_H + 3 * TRACK_H}px`);
|
|
109
|
+
expect(lane?.style.height).toBe(`${TRACK_H}px`);
|
|
155
110
|
act(() => root.unmount());
|
|
156
111
|
});
|
|
157
112
|
});
|
|
@@ -16,7 +16,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
16
16
|
const props = state.canvas;
|
|
17
17
|
const { draggedClip, scrollRef, displayTrackOrder } = props;
|
|
18
18
|
const draggedRowIndex =
|
|
19
|
-
draggedClip?.started === true
|
|
19
|
+
draggedClip?.started === true
|
|
20
|
+
? (draggedClip.insertRow ?? displayTrackOrder.indexOf(draggedClip.previewTrack))
|
|
21
|
+
: -1;
|
|
20
22
|
const dropTrackIndex = props.dropPreview
|
|
21
23
|
? displayTrackOrder.indexOf(props.dropPreview.track)
|
|
22
24
|
: -1;
|
|
@@ -168,7 +170,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
168
170
|
|
|
169
171
|
{/* Insertion line — a new track will be inserted at this boundary on drop.
|
|
170
172
|
Shown while the pointer is near a lane boundary (insert mode). */}
|
|
171
|
-
{insertLineRow != null && (
|
|
173
|
+
{insertLineRow != null && !draggedClip?.started && (
|
|
172
174
|
<div
|
|
173
175
|
data-testid="timeline-insert-line"
|
|
174
176
|
className="absolute pointer-events-none"
|
|
@@ -211,6 +213,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
211
213
|
scrollRef={scrollRef}
|
|
212
214
|
pixelsPerSecond={props.pps}
|
|
213
215
|
rowHeight={draggedClipHeight}
|
|
216
|
+
rowGeometry={props.rowGeometry}
|
|
217
|
+
contentOrigin={props.contentOrigin}
|
|
214
218
|
selectedElementId={props.selectedElementId}
|
|
215
219
|
currentTime={props.currentTime}
|
|
216
220
|
theme={props.theme}
|