@hyperframes/studio 0.8.57 → 0.8.59
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-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
- package/dist/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
- package/dist/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
- package/dist/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
- package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
- package/dist/index.d.ts +577 -8
- package/dist/index.html +2 -2
- package/dist/index.js +22537 -21600
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/TimelineToolbar.tsx +19 -0
- package/src/components/editor/MotionPathOverlay.test.tsx +0 -1
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
- package/src/components/editor/useColorGradingController.ts +2 -6
- package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
- package/src/components/nle/AudioMeterStrip.tsx +213 -0
- package/src/components/nle/NLEContext.tsx +8 -2
- package/src/components/nle/NLEPreview.test.ts +1 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/components/nle/useCompositionStack.ts +1 -1
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useCompositionDimensions.test.tsx +0 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +28 -0
- package/src/hooks/useSdkSession.ts +31 -4
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +25 -1
- package/src/player/components/LayerDisclosureRow.tsx +3 -3
- package/src/player/components/Player.test.ts +34 -0
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineCanvas.tsx +10 -56
- package/src/player/components/TimelineClip.tsx +14 -1
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineGroupHeader.tsx +4 -6
- package/src/player/components/TimelineOverlays.tsx +164 -165
- package/src/player/components/TimelineParts.test.tsx +101 -0
- package/src/player/components/TimelineParts.tsx +74 -0
- package/src/player/components/TimelinePartsParity.test.tsx +58 -0
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
- package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
- package/src/player/hooks/usePlayerHandle.ts +94 -0
- package/src/player/index.ts +7 -0
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/theme.css +4 -0
- package/src/utils/audioMeterMath.test.ts +44 -0
- package/src/utils/audioMeterMath.ts +79 -0
- package/src/utils/audioMeterVisibility.ts +14 -0
- package/src/utils/studioHelpers.ts +21 -0
- package/src/utils/studioUiPreferences.test.ts +10 -0
- package/src/utils/studioUiPreferences.ts +5 -0
- package/src/utils/timelineAssetDrop.test.ts +32 -0
- package/src/utils/timelineAssetDrop.ts +6 -1
|
@@ -16,12 +16,23 @@ import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
|
16
16
|
*
|
|
17
17
|
* Every reason lives in this union so the full surface is readable from one
|
|
18
18
|
* place — `absent_or_empty` included, even though it is a 2xx.
|
|
19
|
+
*
|
|
20
|
+
* `network` is a fetch that REJECTED — the request never produced a response.
|
|
21
|
+
* It was not in this union until 2026-09-21, so it escaped `readProjectFileOptional`
|
|
22
|
+
* and was caught by the effect's outer `.catch`, landing in `stage: "open"`.
|
|
23
|
+
* That label means `openComposition` threw (unparseable composition, OOM), and
|
|
24
|
+
* it is what the largest failure class was reported as. Every `stage: open`
|
|
25
|
+
* event measured on 0.8.56 and 0.8.57 carries a fetch-rejection message —
|
|
26
|
+
* "Failed to fetch", "Load failed", "NetworkError when attempting to fetch
|
|
27
|
+
* resource." — so the class was a network problem filed under a parser one, and
|
|
28
|
+
* unaddressable in that bucket.
|
|
19
29
|
*/
|
|
20
30
|
type ProjectFileReadFailure =
|
|
21
31
|
| { ok: false; reason: "unsafe_path" }
|
|
22
32
|
| { ok: false; reason: "http_error"; status: number }
|
|
23
33
|
| { ok: false; reason: "missing_content" }
|
|
24
|
-
| { ok: false; reason: "absent_or_empty" }
|
|
34
|
+
| { ok: false; reason: "absent_or_empty" }
|
|
35
|
+
| { ok: false; reason: "network" };
|
|
25
36
|
|
|
26
37
|
type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
|
|
27
38
|
|
|
@@ -68,9 +79,20 @@ async function readProjectFileOptional(
|
|
|
68
79
|
// and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
|
|
69
80
|
// already confines both values to single segments of this same-origin URL.
|
|
70
81
|
if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
let res: Response;
|
|
83
|
+
try {
|
|
84
|
+
res = await fetch(
|
|
85
|
+
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
86
|
+
);
|
|
87
|
+
} catch {
|
|
88
|
+
// The request never produced a response: offline, the dev server gone, a
|
|
89
|
+
// CSP/Private-Network-Access block, or an extension rewriting fetch. Caught
|
|
90
|
+
// here so it is reported as a READ failure — left to propagate, the effect's
|
|
91
|
+
// outer catch reported it as `stage: "open"`, which claims the composition
|
|
92
|
+
// failed to parse. Every `stage: open` event before this change was this
|
|
93
|
+
// branch, so the label made the largest class unaddressable.
|
|
94
|
+
return { ok: false, reason: "network" };
|
|
95
|
+
}
|
|
74
96
|
if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
|
|
75
97
|
const data = (await res.json()) as { content?: string };
|
|
76
98
|
// `optional=1` answers a missing file with 200 + `content: ""`, so a
|
|
@@ -299,6 +321,11 @@ export function useSdkSession(
|
|
|
299
321
|
// openComposition threw (unparseable composition, OOM) — same total
|
|
300
322
|
// bypass as the read failure above, but this one is a real defect
|
|
301
323
|
// rather than a missing file. Carry the message; it is the only clue.
|
|
324
|
+
//
|
|
325
|
+
// A rejected read no longer reaches here: `readProjectFileOptional`
|
|
326
|
+
// catches its own fetch rejection and answers `reason: "network"`, so
|
|
327
|
+
// this stage now means what it says. Before that, every event in this
|
|
328
|
+
// bucket was a network error wearing a parser's label.
|
|
302
329
|
trackStudioEvent("sdk_session_unavailable", {
|
|
303
330
|
stage: "open",
|
|
304
331
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
} from "../utils/timelineAssetDrop";
|
|
19
19
|
import { generateId } from "../utils/generateId";
|
|
20
20
|
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
collectHtmlIds,
|
|
23
|
+
resolveDroppedAssetDuration,
|
|
24
|
+
resolveDroppedAssetHasAudio,
|
|
25
|
+
} from "../utils/studioHelpers";
|
|
22
26
|
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
|
|
23
27
|
import { readFileContent } from "./timelineTimingSync";
|
|
24
28
|
import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
|
|
@@ -113,6 +117,9 @@ export function useTimelineAssetDropOps({
|
|
|
113
117
|
? durationOverride
|
|
114
118
|
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
|
115
119
|
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
|
120
|
+
// A video with an audio stream lands audible; the mixer only hears a
|
|
121
|
+
// <video> marked data-has-audio, and a muted drop was losing the sound.
|
|
122
|
+
const hasAudio = await resolveDroppedAssetHasAudio(pid, assetPath, kind);
|
|
116
123
|
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
117
124
|
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
118
125
|
|
|
@@ -146,6 +153,7 @@ export function useTimelineAssetDropOps({
|
|
|
146
153
|
duration: normalizedDuration,
|
|
147
154
|
track,
|
|
148
155
|
zIndex: newElementZIndex,
|
|
156
|
+
hasAudio,
|
|
149
157
|
geometry: fitTimelineAssetGeometry(
|
|
150
158
|
null,
|
|
151
159
|
resolveTimelineAssetCompositionSize(originalContent),
|
package/src/index.ts
CHANGED
|
@@ -46,12 +46,36 @@ export {
|
|
|
46
46
|
VideoThumbnail,
|
|
47
47
|
CompositionThumbnail,
|
|
48
48
|
useTimelinePlayer,
|
|
49
|
+
usePlayerHandle,
|
|
49
50
|
resolveIframe,
|
|
50
51
|
usePlayerStore,
|
|
51
52
|
liveTime,
|
|
52
53
|
formatTime,
|
|
53
54
|
} from "./player";
|
|
54
|
-
export type {
|
|
55
|
+
export type {
|
|
56
|
+
PlayerHandle,
|
|
57
|
+
PlayerHandleElement,
|
|
58
|
+
PlayerHandleListener,
|
|
59
|
+
PlayerHandleTimeListener,
|
|
60
|
+
TimelineElement,
|
|
61
|
+
TimelineTimeRange,
|
|
62
|
+
} from "./player";
|
|
63
|
+
export {
|
|
64
|
+
TimelineFrame,
|
|
65
|
+
TimelineLanes,
|
|
66
|
+
TimelineOverlays,
|
|
67
|
+
TimelinePlayhead,
|
|
68
|
+
TimelineRazorGuide,
|
|
69
|
+
TimelineRuler,
|
|
70
|
+
TimelineEmptyStatePart,
|
|
71
|
+
TimelineEditPopover,
|
|
72
|
+
TimelineClipMenu,
|
|
73
|
+
TimelineGapMenu,
|
|
74
|
+
TimelineKeyframeMenu,
|
|
75
|
+
TimelineShortcutHint,
|
|
76
|
+
} from "./player/components/TimelineParts";
|
|
77
|
+
export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
|
|
78
|
+
export type { TimelineTheme } from "./player/components/timelineTheme";
|
|
55
79
|
|
|
56
80
|
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
|
|
57
81
|
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
@@ -95,9 +95,9 @@ export function LayerDisclosureRow({
|
|
|
95
95
|
<span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
|
|
96
96
|
◇
|
|
97
97
|
</span>
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
<span title={name} className="min-w-0 flex-1 truncate font-medium leading-tight">
|
|
99
|
+
{name}
|
|
100
|
+
</span>
|
|
101
101
|
<TrackClipCount clipCount={clipCount} />
|
|
102
102
|
{children}
|
|
103
103
|
{/* Anchored right, on every header that has one: the lane toggle is the
|
|
@@ -85,6 +85,8 @@ async function mountPlayer(props: Partial<PlayerProps> = {}) {
|
|
|
85
85
|
|
|
86
86
|
const twoFrames = () => act(async () => void (await new Promise((r) => setTimeout(r, 80))));
|
|
87
87
|
|
|
88
|
+
const flushEffects = () => act(async () => await Promise.resolve());
|
|
89
|
+
|
|
88
90
|
function createAudioIframe() {
|
|
89
91
|
const iframe = document.createElement("iframe");
|
|
90
92
|
document.body.appendChild(iframe);
|
|
@@ -216,6 +218,38 @@ describe("ready to show", () => {
|
|
|
216
218
|
const painted = (player: TestHyperframesPlayer) =>
|
|
217
219
|
act(() => void player.dispatchEvent(new Event("painted")));
|
|
218
220
|
|
|
221
|
+
it("waits two animation frames before notifying that the preview can show", async () => {
|
|
222
|
+
const callbacks: FrameRequestCallback[] = [];
|
|
223
|
+
const requestAnimationFrame = vi
|
|
224
|
+
.spyOn(window, "requestAnimationFrame")
|
|
225
|
+
.mockImplementation((callback) => {
|
|
226
|
+
callbacks.push(callback);
|
|
227
|
+
return callbacks.length;
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
const onReadyToShowChange = vi.fn();
|
|
231
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
232
|
+
const el = player as TestHyperframesPlayer;
|
|
233
|
+
|
|
234
|
+
loadAndReady(el);
|
|
235
|
+
painted(el);
|
|
236
|
+
await flushEffects();
|
|
237
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
238
|
+
expect(callbacks).toHaveLength(1);
|
|
239
|
+
|
|
240
|
+
act(() => callbacks.shift()?.(0));
|
|
241
|
+
await flushEffects();
|
|
242
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
243
|
+
expect(callbacks).toHaveLength(1);
|
|
244
|
+
|
|
245
|
+
act(() => callbacks.shift()?.(16));
|
|
246
|
+
await flushEffects();
|
|
247
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
|
|
248
|
+
} finally {
|
|
249
|
+
requestAnimationFrame.mockRestore();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
219
253
|
it("promotes only once the player reports painted, not at ready or assetsready", async () => {
|
|
220
254
|
const onReadyToShowChange = vi.fn();
|
|
221
255
|
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
@@ -135,6 +135,26 @@ function renderBasicTimeline() {
|
|
|
135
135
|
return { host, root };
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
function renderSharedAutomationTimeline(selectedElementId?: string) {
|
|
139
|
+
const host = createSizedTimelineHost(720);
|
|
140
|
+
const automation = JSON.stringify({
|
|
141
|
+
version: 1,
|
|
142
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
|
|
143
|
+
});
|
|
144
|
+
usePlayerStore.setState({
|
|
145
|
+
duration: 8,
|
|
146
|
+
timelineReady: true,
|
|
147
|
+
...(selectedElementId ? { selectedElementId } : {}),
|
|
148
|
+
elements: [
|
|
149
|
+
{ id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
|
|
150
|
+
{ id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
const root = createRoot(host);
|
|
154
|
+
act(() => root.render(React.createElement(Timeline)));
|
|
155
|
+
return { host, root };
|
|
156
|
+
}
|
|
157
|
+
|
|
138
158
|
describe("Timeline provider boundary", () => {
|
|
139
159
|
it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
|
|
140
160
|
usePlayerStore.setState({
|
|
@@ -621,21 +641,7 @@ describe("Timeline provider boundary", () => {
|
|
|
621
641
|
// clip left the row's state depending on the selection, and a collapse that
|
|
622
642
|
// only dropped the active clip left the row stuck open.
|
|
623
643
|
it("expands and collapses every clip on a shared track together", () => {
|
|
624
|
-
const host =
|
|
625
|
-
const automation = JSON.stringify({
|
|
626
|
-
version: 1,
|
|
627
|
-
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
|
|
628
|
-
});
|
|
629
|
-
usePlayerStore.setState({
|
|
630
|
-
duration: 8,
|
|
631
|
-
timelineReady: true,
|
|
632
|
-
elements: [
|
|
633
|
-
{ id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
|
|
634
|
-
{ id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
|
|
635
|
-
],
|
|
636
|
-
});
|
|
637
|
-
const root = createRoot(host);
|
|
638
|
-
act(() => root.render(React.createElement(Timeline)));
|
|
644
|
+
const { host, root } = renderSharedAutomationTimeline();
|
|
639
645
|
|
|
640
646
|
const row = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
|
|
641
647
|
?.parentElement;
|
|
@@ -668,22 +674,7 @@ describe("Timeline provider boundary", () => {
|
|
|
668
674
|
// a lane to select its clip therefore made the handles vanish under the
|
|
669
675
|
// pointer, which is the one gesture the read-only lane exists to support.
|
|
670
676
|
it("keeps the automation lanes mounted when the selection moves along the row", () => {
|
|
671
|
-
const host =
|
|
672
|
-
const automation = JSON.stringify({
|
|
673
|
-
version: 1,
|
|
674
|
-
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
|
|
675
|
-
});
|
|
676
|
-
usePlayerStore.setState({
|
|
677
|
-
duration: 8,
|
|
678
|
-
timelineReady: true,
|
|
679
|
-
selectedElementId: "narration-2",
|
|
680
|
-
elements: [
|
|
681
|
-
{ id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
|
|
682
|
-
{ id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
|
|
683
|
-
],
|
|
684
|
-
});
|
|
685
|
-
const root = createRoot(host);
|
|
686
|
-
act(() => root.render(React.createElement(Timeline)));
|
|
677
|
+
const { host, root } = renderSharedAutomationTimeline("narration-2");
|
|
687
678
|
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]')?.click());
|
|
688
679
|
|
|
689
680
|
const before = [...host.querySelectorAll(".hf-automation-lane")];
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import type { TimelineProps } from "./TimelineTypes";
|
|
3
|
-
import { TimelineEmptyState } from "./TimelineEmptyState";
|
|
4
|
-
import { TimelineCanvas } from "./TimelineCanvas";
|
|
5
|
-
import { TimelineOverlays } from "./TimelineOverlays";
|
|
6
3
|
import { TimelineProvider, useTimelineContext } from "./TimelineProvider";
|
|
4
|
+
import {
|
|
5
|
+
TimelineEmptyStatePart,
|
|
6
|
+
TimelineEditPopover,
|
|
7
|
+
TimelineClipMenu,
|
|
8
|
+
TimelineFrame,
|
|
9
|
+
TimelineGapMenu,
|
|
10
|
+
TimelineKeyframeMenu,
|
|
11
|
+
TimelineLanes,
|
|
12
|
+
TimelineOverlays,
|
|
13
|
+
TimelinePlayhead,
|
|
14
|
+
TimelineRazorGuide,
|
|
15
|
+
TimelineRuler,
|
|
16
|
+
TimelineShortcutHint,
|
|
17
|
+
} from "./TimelineParts";
|
|
7
18
|
|
|
8
19
|
export * from "./TimelineProvider";
|
|
9
20
|
export {
|
|
@@ -28,23 +39,39 @@ function TimelineView() {
|
|
|
28
39
|
const { state, meta } = useTimelineContext();
|
|
29
40
|
const { timelineReady, elements } = state;
|
|
30
41
|
if (!timelineReady || elements.length === 0) {
|
|
31
|
-
return <
|
|
42
|
+
return <TimelineEmptyStatePart />;
|
|
32
43
|
}
|
|
33
44
|
return (
|
|
34
45
|
<div {...meta.containerProps}>
|
|
35
46
|
<div {...meta.viewportProps}>
|
|
36
|
-
<
|
|
37
|
-
|
|
47
|
+
<TimelineFrame />
|
|
48
|
+
<TimelineRazorGuide />
|
|
38
49
|
</div>
|
|
39
50
|
<TimelineOverlays />
|
|
40
51
|
</div>
|
|
41
52
|
);
|
|
42
53
|
}
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
const TimelineComposed = memo(function TimelineComposed(props: TimelineProps = {}) {
|
|
45
56
|
return (
|
|
46
57
|
<TimelineProvider {...props}>
|
|
47
58
|
<TimelineView />
|
|
48
59
|
</TimelineProvider>
|
|
49
60
|
);
|
|
50
61
|
});
|
|
62
|
+
|
|
63
|
+
export const Timeline = Object.assign(TimelineComposed, {
|
|
64
|
+
Provider: TimelineProvider,
|
|
65
|
+
Frame: TimelineFrame,
|
|
66
|
+
Ruler: TimelineRuler,
|
|
67
|
+
Lanes: TimelineLanes,
|
|
68
|
+
Playhead: TimelinePlayhead,
|
|
69
|
+
RazorGuide: TimelineRazorGuide,
|
|
70
|
+
ShortcutHint: TimelineShortcutHint,
|
|
71
|
+
EditPopover: TimelineEditPopover,
|
|
72
|
+
ClipMenu: TimelineClipMenu,
|
|
73
|
+
KeyframeMenu: TimelineKeyframeMenu,
|
|
74
|
+
GapMenu: TimelineGapMenu,
|
|
75
|
+
EmptyState: TimelineEmptyStatePart,
|
|
76
|
+
Overlays: TimelineOverlays,
|
|
77
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { TimelineRulerPart } from "./TimelineRulerPart";
|
|
3
3
|
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
4
4
|
import {
|
|
5
5
|
RULER_H,
|
|
@@ -12,12 +12,8 @@ import {
|
|
|
12
12
|
getTimelineRowTop,
|
|
13
13
|
getTimelineRowHeight,
|
|
14
14
|
} from "./timelineLayout";
|
|
15
|
-
import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
16
|
-
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
17
15
|
import { TimelineLanes } from "./TimelineLanes";
|
|
18
|
-
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
19
16
|
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
|
20
|
-
import { resolveSnapGuide } from "./timelineSnapping";
|
|
21
17
|
import { useTimelineContext } from "./TimelineProvider";
|
|
22
18
|
|
|
23
19
|
// A dropped clip's length is unknown until it lands; the preview shows a default.
|
|
@@ -26,8 +22,7 @@ const DROP_PREVIEW_SECONDS = 3;
|
|
|
26
22
|
export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
27
23
|
const { state, actions } = useTimelineContext();
|
|
28
24
|
const props = state.canvas;
|
|
29
|
-
const { draggedClip,
|
|
30
|
-
const snapGuide = resolveSnapGuide(draggedClip, resizingClip);
|
|
25
|
+
const { draggedClip, scrollRef, displayTrackOrder } = props;
|
|
31
26
|
const draggedRowIndex =
|
|
32
27
|
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
|
33
28
|
const dropTrackIndex = props.dropPreview
|
|
@@ -43,55 +38,14 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
43
38
|
// ghost and drop placeholder must clamp to it or they stretch to the full
|
|
44
39
|
// expanded row height and stop matching the clip being dragged.
|
|
45
40
|
const draggedClipHeight = Math.min(draggedRowHeight, TRACK_H) - CLIP_Y * 2;
|
|
46
|
-
const {
|
|
47
|
-
onResizeElement,
|
|
48
|
-
onMoveElement,
|
|
49
|
-
onToggleTrackHidden,
|
|
50
|
-
onTogglePropertyGroupKeyframe,
|
|
51
|
-
onRazorSplit,
|
|
52
|
-
onRazorSplitAll,
|
|
53
|
-
} = useTimelineEditContextOptional();
|
|
54
41
|
const beatDragging = props.beatDragging;
|
|
55
|
-
const draggedElement
|
|
56
|
-
const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null;
|
|
57
|
-
// The drag ghost follows the cursor freely (both axes) — CapCut-style. The
|
|
58
|
-
// "magnetic" affordance is a highlight on the destination lane (draggedRowIndex),
|
|
59
|
-
// which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it.
|
|
60
|
-
// Live multi-selection drag: while a selected clip is dragged, ALL selected
|
|
61
|
-
// clips move together as one rigid formation. The GRABBED clip is the free
|
|
62
|
-
// ghost below; its co-selected "passengers" slide by the SAME group-clamped
|
|
63
|
-
// delta (cheap translateX, no re-layout) — the delta is derived from the
|
|
64
|
-
// grabbed clip's ALREADY-clamped previewStart, so the whole formation stops at
|
|
65
|
-
// the wall together and never deforms. Matches what the commit will do — see
|
|
66
|
-
// timelineMultiDragPreview + commit.
|
|
67
|
-
const multiDragPreview: MultiDragPreviewInput | null =
|
|
68
|
-
draggedClip?.started === true && draggedElement && draggedElementIdentity
|
|
69
|
-
? {
|
|
70
|
-
dragStarted: true,
|
|
71
|
-
draggedKey: draggedElementIdentity,
|
|
72
|
-
draggedOriginStart: draggedElement.start,
|
|
73
|
-
draggedPreviewStart: draggedClip.previewStart,
|
|
74
|
-
selectedKeys: selectedElementIds,
|
|
75
|
-
}
|
|
76
|
-
: null;
|
|
42
|
+
const { draggedElement, snapGuide, multiDragPreview } = props;
|
|
77
43
|
return (
|
|
78
44
|
<div
|
|
79
45
|
className="relative"
|
|
80
46
|
style={{ height: props.totalH, width: props.contentOrigin + props.trackContentWidth }}
|
|
81
47
|
>
|
|
82
|
-
<
|
|
83
|
-
major={props.major}
|
|
84
|
-
minor={props.minor}
|
|
85
|
-
pps={props.pps}
|
|
86
|
-
trackContentWidth={props.trackContentWidth}
|
|
87
|
-
totalH={props.totalH}
|
|
88
|
-
effectiveDuration={props.effectiveDuration}
|
|
89
|
-
majorTickInterval={props.majorTickInterval}
|
|
90
|
-
theme={props.theme}
|
|
91
|
-
beatAnalysis={props.beatAnalysis}
|
|
92
|
-
contentOrigin={props.contentOrigin}
|
|
93
|
-
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
|
|
94
|
-
/>
|
|
48
|
+
<TimelineRulerPart />
|
|
95
49
|
|
|
96
50
|
{/* Breathing room between the sticky ruler and the first track lane — the
|
|
97
51
|
top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
|
|
@@ -104,12 +58,12 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
104
58
|
snapGuide={snapGuide}
|
|
105
59
|
draggedElement={draggedElement}
|
|
106
60
|
multiDragPreview={multiDragPreview}
|
|
107
|
-
onToggleTrackHidden={onToggleTrackHidden}
|
|
108
|
-
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
|
109
|
-
onResizeElement={onResizeElement}
|
|
110
|
-
onMoveElement={onMoveElement}
|
|
111
|
-
onRazorSplit={onRazorSplit}
|
|
112
|
-
onRazorSplitAll={onRazorSplitAll}
|
|
61
|
+
onToggleTrackHidden={props.onToggleTrackHidden}
|
|
62
|
+
onTogglePropertyGroupKeyframe={props.onTogglePropertyGroupKeyframe}
|
|
63
|
+
onResizeElement={props.onResizeElement}
|
|
64
|
+
onMoveElement={props.onMoveElement}
|
|
65
|
+
onRazorSplit={props.onRazorSplit}
|
|
66
|
+
onRazorSplitAll={props.onRazorSplitAll}
|
|
113
67
|
/>
|
|
114
68
|
|
|
115
69
|
{/* Breathing room below the last track lane (~1.5 track heights) — a real
|
|
@@ -4,6 +4,7 @@ import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "
|
|
|
4
4
|
import type { TimelineEditCapabilities } from "./timelineEditing";
|
|
5
5
|
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
6
6
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
7
|
+
import { TimelineClipFades } from "./TimelineClipFades";
|
|
7
8
|
|
|
8
9
|
interface TimelineClipProps {
|
|
9
10
|
el: TimelineElement;
|
|
@@ -74,6 +75,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
74
75
|
"--clip-border-active": theme.clipBorderActive,
|
|
75
76
|
"--clip-handle": theme.handleColor,
|
|
76
77
|
} as CSSProperties;
|
|
78
|
+
const isAudioClip = isAudioTimelineElement(el);
|
|
77
79
|
const clipClassName = [
|
|
78
80
|
"timeline-clip",
|
|
79
81
|
"absolute",
|
|
@@ -81,7 +83,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
81
83
|
isSelected ? "is-selected" : "",
|
|
82
84
|
isHovered ? "is-hovered" : "",
|
|
83
85
|
isDragging ? "is-dragging" : "",
|
|
84
|
-
|
|
86
|
+
isAudioClip ? "is-audio" : "",
|
|
85
87
|
]
|
|
86
88
|
.filter((className) => className.length > 0)
|
|
87
89
|
.join(" ");
|
|
@@ -198,6 +200,17 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
198
200
|
</span>
|
|
199
201
|
)}
|
|
200
202
|
{children}
|
|
203
|
+
{/* Fade handles + ramps for anything the mixer hears — audio clips and
|
|
204
|
+
videos marked data-has-audio. They write data-fade-in/out on the clip
|
|
205
|
+
and are the timeline half of the inspector's Fade rows. */}
|
|
206
|
+
{(isAudioClip || el.hasAudio) && !isGestureActor && (
|
|
207
|
+
<TimelineClipFades
|
|
208
|
+
el={el}
|
|
209
|
+
pps={pps}
|
|
210
|
+
widthPx={widthPx}
|
|
211
|
+
showHandles={(isHovered || isSelected) && !isDragging}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
201
214
|
</button>
|
|
202
215
|
);
|
|
203
216
|
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { TimelineClipFades } from "./TimelineClipFades";
|
|
8
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const clip: TimelineElement = {
|
|
17
|
+
id: "music",
|
|
18
|
+
tag: "audio",
|
|
19
|
+
src: "assets/music.wav",
|
|
20
|
+
start: 2,
|
|
21
|
+
duration: 10,
|
|
22
|
+
track: 1,
|
|
23
|
+
fadeIn: 1,
|
|
24
|
+
fadeOut: 2,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function render(
|
|
28
|
+
el: TimelineElement,
|
|
29
|
+
options: { showHandles?: boolean; provide?: boolean; onClipPointerDown?: () => void } = {},
|
|
30
|
+
) {
|
|
31
|
+
const host = document.createElement("div");
|
|
32
|
+
document.body.append(host);
|
|
33
|
+
const root = createRoot(host);
|
|
34
|
+
const onSetElementAttributeLive = vi.fn();
|
|
35
|
+
const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined);
|
|
36
|
+
// Stands in for the TimelineClip button the handles live inside.
|
|
37
|
+
const node = (
|
|
38
|
+
<div data-testid="clip" onPointerDown={options.onClipPointerDown}>
|
|
39
|
+
<TimelineClipFades
|
|
40
|
+
el={el}
|
|
41
|
+
pps={100}
|
|
42
|
+
widthPx={el.duration * 100}
|
|
43
|
+
showHandles={options.showHandles ?? true}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
act(() => {
|
|
48
|
+
root.render(
|
|
49
|
+
options.provide === false ? (
|
|
50
|
+
node
|
|
51
|
+
) : (
|
|
52
|
+
<TimelineEditProvider value={{ onSetElementAttributeLive, onSetElementAttributeQuiet }}>
|
|
53
|
+
{node}
|
|
54
|
+
</TimelineEditProvider>
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
return { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pointer(type: string, clientX: number, pointerId = 1) {
|
|
62
|
+
const event = new MouseEvent(type, { bubbles: true, clientX, button: 0 });
|
|
63
|
+
Object.defineProperty(event, "pointerId", { value: pointerId });
|
|
64
|
+
return event;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function armCapture(handle: HTMLElement) {
|
|
68
|
+
let captured = false;
|
|
69
|
+
Object.defineProperty(handle, "setPointerCapture", { value: () => (captured = true) });
|
|
70
|
+
Object.defineProperty(handle, "releasePointerCapture", { value: () => (captured = false) });
|
|
71
|
+
Object.defineProperty(handle, "hasPointerCapture", { value: () => captured });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("TimelineClipFades", () => {
|
|
75
|
+
it("shades the fade-in and fade-out as ramps sized by pps", () => {
|
|
76
|
+
const { host, root } = render(clip, { showHandles: false, provide: false });
|
|
77
|
+
const fadeIn = host.querySelector('[data-testid="clip-fade-in"]');
|
|
78
|
+
const fadeOut = host.querySelector('[data-testid="clip-fade-out"]');
|
|
79
|
+
// 1 s in at 100 px/s: the wedge spans 0..100; 2 s out on a 1000 px clip: 800..1000.
|
|
80
|
+
expect(fadeIn?.getAttribute("points")).toBe("0,0 100,0 0,100");
|
|
81
|
+
expect(fadeOut?.getAttribute("points")).toBe("800,0 1000,0 1000,100");
|
|
82
|
+
// Outside a provider there is nothing to write to, so no handles either.
|
|
83
|
+
expect(host.querySelector('[data-testid="clip-fade-handle-in"]')).toBeNull();
|
|
84
|
+
act(() => root.unmount());
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("renders nothing for a clip without fades when the handles are hidden", () => {
|
|
88
|
+
const { host, root } = render(
|
|
89
|
+
{ ...clip, fadeIn: undefined, fadeOut: undefined },
|
|
90
|
+
{
|
|
91
|
+
showHandles: false,
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
expect(host.querySelector('[data-testid="clip"]')?.innerHTML).toBe("");
|
|
95
|
+
act(() => root.unmount());
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("drags the fade-in dot to the right, previewing live and committing once on release", () => {
|
|
99
|
+
const { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip);
|
|
100
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
101
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
102
|
+
armCapture(handle);
|
|
103
|
+
// The handle sits at the 1 s mark (x=100). Drag 150 px right → 2.5 s.
|
|
104
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
105
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 200)));
|
|
106
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 250)));
|
|
107
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "2.5");
|
|
108
|
+
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
|
109
|
+
// The ramp follows the pointer before anything is persisted.
|
|
110
|
+
expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
|
|
111
|
+
"0,0 250,0 0,100",
|
|
112
|
+
);
|
|
113
|
+
act(() => handle.dispatchEvent(pointer("pointerup", 250)));
|
|
114
|
+
expect(onSetElementAttributeQuiet).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", "2.5", "Fade in");
|
|
116
|
+
act(() => root.unmount());
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("drags the fade-out dot to the left, growing the fade, and never past the fade-in", () => {
|
|
120
|
+
const { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip);
|
|
121
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
122
|
+
if (!handle) throw new Error("expected a fade-out handle");
|
|
123
|
+
armCapture(handle);
|
|
124
|
+
// Handle at x=800 (2 s before the end). 300 px left → 5 s.
|
|
125
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 800)));
|
|
126
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 500)));
|
|
127
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
|
|
128
|
+
// Way past the start: clamps to duration − fadeIn = 9 s.
|
|
129
|
+
act(() => handle.dispatchEvent(pointer("pointermove", -500)));
|
|
130
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "9");
|
|
131
|
+
act(() => handle.dispatchEvent(pointer("pointerup", -500)));
|
|
132
|
+
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-out", "9", "Fade out");
|
|
133
|
+
act(() => root.unmount());
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("removes the attribute when dragged back to zero", () => {
|
|
137
|
+
const { host, root, onSetElementAttributeQuiet } = render(clip);
|
|
138
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
139
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
140
|
+
armCapture(handle);
|
|
141
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
142
|
+
act(() => handle.dispatchEvent(pointer("pointermove", -40)));
|
|
143
|
+
act(() => handle.dispatchEvent(pointer("pointerup", -40)));
|
|
144
|
+
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", null, "Fade in");
|
|
145
|
+
act(() => root.unmount());
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("puts the live value back and writes nothing when the gesture is cancelled", () => {
|
|
149
|
+
const { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip);
|
|
150
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
151
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
152
|
+
armCapture(handle);
|
|
153
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
154
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 300)));
|
|
155
|
+
act(() => handle.dispatchEvent(pointer("pointercancel", 300)));
|
|
156
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
|
|
157
|
+
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
|
158
|
+
act(() => root.unmount());
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("does not let a press on the dot start the clip's own move gesture", () => {
|
|
162
|
+
const outer = vi.fn();
|
|
163
|
+
const { host, root } = render(clip, { onClipPointerDown: outer });
|
|
164
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
165
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
166
|
+
armCapture(handle);
|
|
167
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
168
|
+
expect(outer).not.toHaveBeenCalled();
|
|
169
|
+
act(() => root.unmount());
|
|
170
|
+
});
|
|
171
|
+
});
|