@hyperframes/studio 0.8.117 → 0.8.119
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-5fcgSstJ.js → hyperframes-player-7Icl2uVN.js} +1 -1
- package/dist/assets/{index-XJSawwYe.js → index-7YcUqkoh.js} +1 -1
- package/dist/assets/{index-C5qKdpgl.js → index-DOO2cYhl.js} +189 -189
- package/dist/assets/{index-tg2LXUeu.js → index-Dc8FEIEU.js} +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.html +1 -1
- package/dist/index.js +2289 -2241
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/StudioRightPanels.tsx +3 -6
- package/src/components/TimelineToolbar.test.tsx +14 -0
- package/src/components/TimelineToolbar.tsx +4 -1
- package/src/components/editor/PropertyPanel.test.tsx +149 -25
- package/src/components/editor/PropertyPanel.tsx +1 -12
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +2 -3
- package/src/components/editor/PropertyPanelEmptyState.tsx +3 -5
- package/src/components/editor/PropertyPanelFlat.tsx +9 -22
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +7 -4
- package/src/components/editor/PropertyPanelFlatHeader.tsx +5 -3
- package/src/components/editor/usePanelHiddenToggle.ts +44 -0
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/hooks/timelineTrackVisibility.ts +7 -17
- package/src/player/components/Timeline.test.ts +48 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +14 -22
- package/src/player/components/TimelineTrackHeader.tsx +4 -2
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +17 -31
- package/src/player/components/TimelineTrackPlainHeader.tsx +9 -32
- package/src/player/components/hiddenToggle.tsx +20 -0
- package/src/utils/gsapSoftReload.test.ts +12 -2
- package/src/utils/gsapSoftReload.ts +4 -10
- package/src/utils/timelineInspector.ts +10 -5
|
@@ -499,6 +499,54 @@ describe("Timeline provider boundary", () => {
|
|
|
499
499
|
act(() => root.unmount());
|
|
500
500
|
});
|
|
501
501
|
|
|
502
|
+
// An agent writes music alternatives muted; the author unmutes one and must be
|
|
503
|
+
// able to mute it again from the same row.
|
|
504
|
+
it("mutes, unmutes and mutes an audio track again from its header", () => {
|
|
505
|
+
const host = createSizedTimelineHost(640);
|
|
506
|
+
usePlayerStore.setState({
|
|
507
|
+
duration: 4,
|
|
508
|
+
timelineReady: true,
|
|
509
|
+
elements: [{ id: "music-b", tag: "audio", start: 0, duration: 4, track: 0, hidden: true }],
|
|
510
|
+
});
|
|
511
|
+
const onToggleTrackHidden = vi.fn((track: number, hidden: boolean) => {
|
|
512
|
+
usePlayerStore.setState({
|
|
513
|
+
elements: usePlayerStore
|
|
514
|
+
.getState()
|
|
515
|
+
.elements.map((el) => (el.track === track ? { ...el, hidden } : el)),
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
const root = createRoot(host);
|
|
519
|
+
act(() => {
|
|
520
|
+
root.render(
|
|
521
|
+
React.createElement(
|
|
522
|
+
TimelineEditProvider,
|
|
523
|
+
{ value: { onToggleTrackHidden } },
|
|
524
|
+
React.createElement(Timeline),
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
act(() => {});
|
|
529
|
+
|
|
530
|
+
const press = (label: string) => {
|
|
531
|
+
const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
|
|
532
|
+
if (!button) throw new Error(`Expected a "${label}" button`);
|
|
533
|
+
act(() => button.click());
|
|
534
|
+
};
|
|
535
|
+
press("Unmute track 1");
|
|
536
|
+
press("Mute track 1");
|
|
537
|
+
press("Unmute track 1");
|
|
538
|
+
press("Mute track 1");
|
|
539
|
+
|
|
540
|
+
expect(onToggleTrackHidden.mock.calls.map((call) => call[1])).toEqual([
|
|
541
|
+
false,
|
|
542
|
+
true,
|
|
543
|
+
false,
|
|
544
|
+
true,
|
|
545
|
+
]);
|
|
546
|
+
expect(usePlayerStore.getState().elements[0]?.hidden).toBe(true);
|
|
547
|
+
act(() => root.unmount());
|
|
548
|
+
});
|
|
549
|
+
|
|
502
550
|
it("splits all tracks once when shift-clicking the timeline with the razor", () => {
|
|
503
551
|
const host = createSizedTimelineHost(640);
|
|
504
552
|
usePlayerStore.setState({
|
|
@@ -256,43 +256,35 @@ describe("TimelineTrackHeader", () => {
|
|
|
256
256
|
act(() => view.root.unmount());
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
259
|
+
// `data-hidden` silences an audio track, so its toggle is a mute, offered in
|
|
260
|
+
// both states: an agent writes alternatives muted and the author flips them.
|
|
261
|
+
it.each([
|
|
262
|
+
[false, "Mute track 1"],
|
|
263
|
+
[true, "Unmute track 1"],
|
|
264
|
+
])("offers the audio track toggle when hidden=%s", (isTrackHidden, label) => {
|
|
263
265
|
const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
|
|
264
266
|
const view = renderHeader({
|
|
265
267
|
keyframeClip: audio,
|
|
266
268
|
trackElements: [audio],
|
|
267
269
|
isAudioTrack: true,
|
|
270
|
+
isTrackHidden,
|
|
268
271
|
animations: [],
|
|
269
272
|
});
|
|
270
|
-
|
|
271
|
-
b.getAttribute("aria-label"),
|
|
272
|
-
);
|
|
273
|
-
expect(labels.some((l) => l && /^(Hide|Show) track/.test(l))).toBe(false);
|
|
274
|
-
expect(labels).not.toContain("Mute");
|
|
273
|
+
expect(view.host.querySelector(`button[aria-label="${label}"]`)).not.toBeNull();
|
|
275
274
|
act(() => view.root.unmount());
|
|
276
275
|
});
|
|
277
276
|
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// meant a track hidden by "Hide all" (or by hand, or before that rule existed)
|
|
282
|
-
// was silent with no control anywhere to bring it back.
|
|
283
|
-
it("offers the eye on an audio track that is already hidden, so it can be restored", () => {
|
|
284
|
-
const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
|
|
277
|
+
// Same rule as the undo entry: a track with any visual clip hides, it does not mute.
|
|
278
|
+
it("offers the eye on a track mixing audio and a visual clip", () => {
|
|
279
|
+
const audio: TimelineElement = { ...ELEMENT, id: "vo", tag: "audio" };
|
|
285
280
|
const view = renderHeader({
|
|
286
281
|
keyframeClip: audio,
|
|
287
|
-
trackElements: [audio],
|
|
282
|
+
trackElements: [audio, ELEMENT],
|
|
283
|
+
clipCount: 2,
|
|
288
284
|
isAudioTrack: true,
|
|
289
|
-
isTrackHidden: true,
|
|
290
285
|
animations: [],
|
|
291
286
|
});
|
|
292
|
-
|
|
293
|
-
b.getAttribute("aria-label"),
|
|
294
|
-
);
|
|
295
|
-
expect(labels.some((l) => l && /^Show track/.test(l))).toBe(true);
|
|
287
|
+
expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
|
|
296
288
|
act(() => view.root.unmount());
|
|
297
289
|
});
|
|
298
290
|
|
|
@@ -12,6 +12,7 @@ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditConte
|
|
|
12
12
|
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
13
13
|
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
|
|
14
14
|
import { runtimeAudioId } from "../lib/timelineElementHelpers";
|
|
15
|
+
import { isAudioOnlyTrack } from "../../utils/timelineInspector";
|
|
15
16
|
import { TimelineFxButton } from "./TimelineFxButton";
|
|
16
17
|
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
17
18
|
import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData";
|
|
@@ -203,6 +204,7 @@ export function TimelineTrackHeader({
|
|
|
203
204
|
// disclosability swapped it for the keyframe-layer row (a `◇`, no indent) the
|
|
204
205
|
// moment an envelope appeared.
|
|
205
206
|
const isKeyframeLayer = !!keyframeClip && disclosable && !isAudioTrack;
|
|
207
|
+
const isAudioOnly = isAudioOnlyTrack(trackElements);
|
|
206
208
|
// What the lane disclosure calls this row. A row of several clips is named
|
|
207
209
|
// for the TRACK, not for whichever is selected — the lanes are the track's,
|
|
208
210
|
// shared per property, so "Narration 2 lanes" read as if they were that one
|
|
@@ -312,6 +314,7 @@ export function TimelineTrackHeader({
|
|
|
312
314
|
showTrackLabel={showTrackLabel}
|
|
313
315
|
isTrackHidden={isTrackHidden}
|
|
314
316
|
isAudioTrack={isAudioTrack}
|
|
317
|
+
isAudioOnly={isAudioOnly}
|
|
315
318
|
onToggleTrackHidden={onToggleTrackHidden}
|
|
316
319
|
// On the control line rather than a third row of its own.
|
|
317
320
|
trailing={
|
|
@@ -383,8 +386,7 @@ export function TimelineTrackHeader({
|
|
|
383
386
|
hidden={isTrackHidden}
|
|
384
387
|
trackNumber={trackNumber}
|
|
385
388
|
trackDisplayNumber={trackDisplayNumber}
|
|
386
|
-
|
|
387
|
-
visible={!isAudioTrack || isTrackHidden}
|
|
389
|
+
asMute={isAudioOnly}
|
|
388
390
|
onToggle={onToggleTrackHidden}
|
|
389
391
|
/>
|
|
390
392
|
</LayerDisclosureRow>
|
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
* The wording ran behind the `audio-track-mute` canary at 0%, so it had never
|
|
7
|
-
* rendered in any suite: it returned "Muted" / "Mute", which named the CURRENT
|
|
8
|
-
* state rather than the action, and dropped the track suffix so every audio row
|
|
9
|
-
* shared one accessible name. Pinned here because the label, the icon and the
|
|
10
|
-
* callback's arguments are the whole identity of this control.
|
|
11
|
-
*
|
|
12
|
-
* The mute presentation itself is gone (`remove mute and solo from tracks and
|
|
13
|
-
* groups`): the control is the visibility eye it always was, and an audio row
|
|
14
|
-
* renders it only when already hidden, which is the way back out.
|
|
4
|
+
* The track toggle's accessible contract: the label names the action, carries
|
|
5
|
+
* the display row, and reads mute on an audio-only track.
|
|
15
6
|
*/
|
|
16
7
|
|
|
17
8
|
import React, { act } from "react";
|
|
@@ -27,7 +18,7 @@ afterEach(() => {
|
|
|
27
18
|
|
|
28
19
|
function renderButton(props: {
|
|
29
20
|
hidden: boolean;
|
|
30
|
-
|
|
21
|
+
asMute?: boolean;
|
|
31
22
|
trackDisplayNumber: number | null;
|
|
32
23
|
}): { host: HTMLElement; unmount: () => void; onToggle: ReturnType<typeof vi.fn> } {
|
|
33
24
|
const host = document.createElement("div");
|
|
@@ -40,7 +31,7 @@ function renderButton(props: {
|
|
|
40
31
|
hidden: props.hidden,
|
|
41
32
|
trackNumber: 7,
|
|
42
33
|
trackDisplayNumber: props.trackDisplayNumber,
|
|
43
|
-
|
|
34
|
+
asMute: props.asMute ?? false,
|
|
44
35
|
onToggle,
|
|
45
36
|
}),
|
|
46
37
|
),
|
|
@@ -80,13 +71,13 @@ describe("VisibilityButton", () => {
|
|
|
80
71
|
view.unmount();
|
|
81
72
|
});
|
|
82
73
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
expect(
|
|
89
|
-
|
|
74
|
+
it("names a mute on an audio-only track", () => {
|
|
75
|
+
const audible = renderButton({ hidden: false, asMute: true, trackDisplayNumber: 2 });
|
|
76
|
+
expect(labelOf(audible.host)).toBe("Mute track 2");
|
|
77
|
+
audible.unmount();
|
|
78
|
+
const muted = renderButton({ hidden: true, asMute: true, trackDisplayNumber: 2 });
|
|
79
|
+
expect(labelOf(muted.host)).toBe("Unmute track 2");
|
|
80
|
+
muted.unmount();
|
|
90
81
|
});
|
|
91
82
|
});
|
|
92
83
|
|
|
@@ -104,6 +95,7 @@ describe("PlainTrackHeader", () => {
|
|
|
104
95
|
clipCount: 1,
|
|
105
96
|
isTrackHidden: false,
|
|
106
97
|
isAudioTrack: true,
|
|
98
|
+
isAudioOnly: true,
|
|
107
99
|
onToggleTrackHidden: vi.fn(),
|
|
108
100
|
showTrackLabel: true,
|
|
109
101
|
...overrides,
|
|
@@ -113,26 +105,20 @@ describe("PlainTrackHeader", () => {
|
|
|
113
105
|
return { host, unmount: () => act(() => root.unmount()) };
|
|
114
106
|
}
|
|
115
107
|
|
|
116
|
-
|
|
117
|
-
// affordance sitting in the eye's slot.
|
|
118
|
-
it("withholds the eye from an audible audio track", () => {
|
|
108
|
+
it("offers a mute on an audible audio track", () => {
|
|
119
109
|
const view = renderHeader();
|
|
120
|
-
expect(view.host.
|
|
110
|
+
expect(labelOf(view.host)).toBe("Mute track 1");
|
|
121
111
|
view.unmount();
|
|
122
112
|
});
|
|
123
113
|
|
|
124
|
-
|
|
125
|
-
// silences the clip in preview and drops it from the render, and nothing else
|
|
126
|
-
// writes it, so a track hidden by hand or by "Hide all" was silent with no
|
|
127
|
-
// control anywhere to restore it.
|
|
128
|
-
it("offers it back once the audio track is hidden", () => {
|
|
114
|
+
it("offers the unmute on a muted audio track", () => {
|
|
129
115
|
const view = renderHeader({ isTrackHidden: true });
|
|
130
|
-
expect(labelOf(view.host)).toBe("
|
|
116
|
+
expect(labelOf(view.host)).toBe("Unmute track 1");
|
|
131
117
|
view.unmount();
|
|
132
118
|
});
|
|
133
119
|
|
|
134
120
|
it("keeps the eye on a visual track", () => {
|
|
135
|
-
const view = renderHeader({ isAudioTrack: false });
|
|
121
|
+
const view = renderHeader({ isAudioTrack: false, isAudioOnly: false });
|
|
136
122
|
expect(labelOf(view.host)).toBe("Hide track 1");
|
|
137
123
|
view.unmount();
|
|
138
124
|
});
|
|
@@ -1,39 +1,28 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
-
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
|
3
2
|
import { Music } from "../../icons/SystemIcons";
|
|
4
3
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
5
4
|
import { TrackClipCount } from "./TrackClipCount";
|
|
6
5
|
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
7
|
-
|
|
8
|
-
// Hide, plainly. The speaker variant was the mute presentation; with mute gone
|
|
9
|
-
// this is the visibility eye it always was, and audio rows do not render it.
|
|
10
|
-
function visibilityButtonLabel(hidden: boolean, suffix: string): string {
|
|
11
|
-
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function visibilityButtonIcon(hidden: boolean) {
|
|
15
|
-
const Icon = hidden ? EyeSlash : Eye;
|
|
16
|
-
return <Icon size={14} weight="bold" aria-hidden="true" />;
|
|
17
|
-
}
|
|
6
|
+
import { HiddenToggleIcon, hiddenToggleVerb } from "./hiddenToggle";
|
|
18
7
|
|
|
19
8
|
export function VisibilityButton({
|
|
20
9
|
hidden,
|
|
21
10
|
trackNumber,
|
|
22
11
|
trackDisplayNumber,
|
|
23
|
-
|
|
12
|
+
asMute,
|
|
24
13
|
onToggle,
|
|
25
14
|
}: {
|
|
26
15
|
hidden: boolean;
|
|
27
16
|
trackNumber: number;
|
|
28
17
|
trackDisplayNumber: number | null;
|
|
29
|
-
|
|
18
|
+
/** `data-hidden` silences an audio-only track, so its toggle is a mute. */
|
|
19
|
+
asMute: boolean;
|
|
30
20
|
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
31
21
|
}) {
|
|
32
|
-
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
|
33
22
|
// Display number in the text, real key in the callback. The two must not be
|
|
34
23
|
// conflated in either direction.
|
|
35
24
|
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
|
36
|
-
const label =
|
|
25
|
+
const label = `${hiddenToggleVerb(asMute, hidden)} track${suffix}`;
|
|
37
26
|
return (
|
|
38
27
|
<button
|
|
39
28
|
type="button"
|
|
@@ -52,7 +41,7 @@ export function VisibilityButton({
|
|
|
52
41
|
void onToggle?.(trackNumber, !hidden, trackDisplayNumber);
|
|
53
42
|
}}
|
|
54
43
|
>
|
|
55
|
-
{
|
|
44
|
+
<HiddenToggleIcon asMute={asMute} hidden={hidden} size={14} />
|
|
56
45
|
</button>
|
|
57
46
|
);
|
|
58
47
|
}
|
|
@@ -67,6 +56,7 @@ export function PlainTrackHeader({
|
|
|
67
56
|
showTrackLabel,
|
|
68
57
|
isTrackHidden,
|
|
69
58
|
isAudioTrack,
|
|
59
|
+
isAudioOnly,
|
|
70
60
|
onToggleTrackHidden,
|
|
71
61
|
trailing,
|
|
72
62
|
}: {
|
|
@@ -76,6 +66,7 @@ export function PlainTrackHeader({
|
|
|
76
66
|
clipCount: number;
|
|
77
67
|
isTrackHidden: boolean;
|
|
78
68
|
isAudioTrack: boolean;
|
|
69
|
+
isAudioOnly: boolean;
|
|
79
70
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
80
71
|
showTrackLabel: boolean;
|
|
81
72
|
/** Trailing controls that belong on the control line — the FX entry points,
|
|
@@ -111,25 +102,11 @@ export function PlainTrackHeader({
|
|
|
111
102
|
truncating name leaves, so the controls sit on the edge whatever the
|
|
112
103
|
name's length. */}
|
|
113
104
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
114
|
-
{/* Not on an audio track. The control is the old visibility eye, and
|
|
115
|
-
on audio it silences rather than hides — but a row that already says
|
|
116
|
-
what it is with a speaker does not also need the hide affordance
|
|
117
|
-
sitting in the eye's slot. `visible={false}` rather than omitting the
|
|
118
|
-
element, so the spacer keeps every row's control columns aligned.
|
|
119
|
-
|
|
120
|
-
EXCEPT when the audio track is ALREADY hidden. Withholding the
|
|
121
|
-
control unconditionally withheld the only way back: `data-hidden`
|
|
122
|
-
silences the clip in preview and drops it from the render, the
|
|
123
|
-
panel's "Muted" is the unrelated HTML `muted` attribute, and nothing
|
|
124
|
-
else writes it — so a track hidden before this rule (or by "Hide
|
|
125
|
-
all", or by hand) was silent with no control anywhere to restore it.
|
|
126
|
-
Offering the eye only in that state keeps the affordance off a normal
|
|
127
|
-
audio row while leaving the door open from the inside. */}
|
|
128
105
|
<VisibilityButton
|
|
129
106
|
hidden={isTrackHidden}
|
|
130
107
|
trackNumber={trackNumber}
|
|
131
108
|
trackDisplayNumber={trackDisplayNumber}
|
|
132
|
-
|
|
109
|
+
asMute={isAudioOnly}
|
|
133
110
|
onToggle={onToggleTrackHidden}
|
|
134
111
|
/>
|
|
135
112
|
{trailing}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
|
2
|
+
|
|
3
|
+
/** The action a hide toggle offers from the current `hidden` state; on audio `data-hidden` is a mute. */
|
|
4
|
+
export function hiddenToggleVerb(asMute: boolean, hidden: boolean): string {
|
|
5
|
+
if (asMute) return hidden ? "Unmute" : "Mute";
|
|
6
|
+
return hidden ? "Show" : "Hide";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function HiddenToggleIcon({
|
|
10
|
+
asMute,
|
|
11
|
+
hidden,
|
|
12
|
+
size,
|
|
13
|
+
}: {
|
|
14
|
+
asMute: boolean;
|
|
15
|
+
hidden: boolean;
|
|
16
|
+
size: number;
|
|
17
|
+
}) {
|
|
18
|
+
const Icon = asMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
|
|
19
|
+
return <Icon size={size} weight="bold" aria-hidden="true" />;
|
|
20
|
+
}
|
|
@@ -249,7 +249,7 @@ describe("applySoftReload", () => {
|
|
|
249
249
|
// gsap present but MotionPathPlugin unset → async load path.
|
|
250
250
|
const { iframe, contentWindow } = buildMockIframe({
|
|
251
251
|
MotionPathPlugin: undefined,
|
|
252
|
-
gsap: { timeline: vi.fn(), registerPlugin: vi.fn() },
|
|
252
|
+
gsap: { timeline: vi.fn(), registerPlugin: vi.fn(), version: "3.14.2" },
|
|
253
253
|
});
|
|
254
254
|
(iframe.contentDocument as unknown as { head: unknown }).head = head;
|
|
255
255
|
|
|
@@ -260,7 +260,7 @@ describe("applySoftReload", () => {
|
|
|
260
260
|
// script has NOT executed yet, so the timeline isn't rebound synchronously.
|
|
261
261
|
expect(result).toBe("applied");
|
|
262
262
|
expect(appendedScripts).toHaveLength(1);
|
|
263
|
-
expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
|
|
263
|
+
expect(appendedScripts[0]!.src).toContain("gsap@3.14.2/dist/MotionPathPlugin");
|
|
264
264
|
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
|
|
265
265
|
|
|
266
266
|
// onerror must NOT run the script (that would reference a missing plugin) —
|
|
@@ -384,6 +384,16 @@ describe("ensureMotionPathPluginLoaded", () => {
|
|
|
384
384
|
expect(appendedScripts).toHaveLength(0);
|
|
385
385
|
});
|
|
386
386
|
|
|
387
|
+
it("loads the plugin at the composition's own gsap version", () => {
|
|
388
|
+
const { iframe, appendedScripts } = buildBootstrapIframe({
|
|
389
|
+
gsap: { version: "3.14.2", registerPlugin: vi.fn() },
|
|
390
|
+
});
|
|
391
|
+
ensureMotionPathPluginLoaded(iframe);
|
|
392
|
+
expect(appendedScripts[0]!.src).toBe(
|
|
393
|
+
"https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js",
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
|
|
387
397
|
it("appends the plugin script once and registers it on load", () => {
|
|
388
398
|
const { iframe, contentWindow, appendedScripts, registerPlugin } = buildBootstrapIframe();
|
|
389
399
|
ensureMotionPathPluginLoaded(iframe);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
|
2
|
+
import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
|
|
2
3
|
import { findAuthoredElement } from "./authoredSource";
|
|
3
4
|
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
|
|
4
5
|
import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
|
|
@@ -14,6 +15,7 @@ type IframeWindow = Window & {
|
|
|
14
15
|
// re-flash the iframe. Cleared once the plugin loads or errors.
|
|
15
16
|
__hfMotionPathPluginLoading?: boolean;
|
|
16
17
|
gsap?: {
|
|
18
|
+
version?: string;
|
|
17
19
|
timeline?: (...args: unknown[]) => unknown;
|
|
18
20
|
registerPlugin?: (...plugins: unknown[]) => unknown;
|
|
19
21
|
set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
|
|
@@ -22,14 +24,6 @@ type IframeWindow = Window & {
|
|
|
22
24
|
MotionPathPlugin?: unknown;
|
|
23
25
|
};
|
|
24
26
|
|
|
25
|
-
/**
|
|
26
|
-
* CDN URL for the GSAP MotionPathPlugin. Shared between the one-time preview
|
|
27
|
-
* bootstrap (ensureMotionPathPluginLoaded) and the soft-reload fallback so the
|
|
28
|
-
* version is pinned in a single place.
|
|
29
|
-
*/
|
|
30
|
-
const MOTION_PATH_PLUGIN_CDN =
|
|
31
|
-
"https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
|
|
32
|
-
|
|
33
27
|
/**
|
|
34
28
|
* Pre-load + register MotionPathPlugin ONCE in the preview iframe so
|
|
35
29
|
* `win.MotionPathPlugin` is reliably set before any studio edit. Called from the
|
|
@@ -66,7 +60,7 @@ export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null):
|
|
|
66
60
|
try {
|
|
67
61
|
win.__hfMotionPathPluginLoading = true;
|
|
68
62
|
const pluginScript = doc.createElement("script");
|
|
69
|
-
pluginScript.src =
|
|
63
|
+
pluginScript.src = motionPathPluginUrl(win.gsap?.version);
|
|
70
64
|
const finalize = () => {
|
|
71
65
|
win.__hfMotionPathPluginLoading = false;
|
|
72
66
|
try {
|
|
@@ -445,7 +439,7 @@ export function applySoftReload(
|
|
|
445
439
|
}
|
|
446
440
|
win.__hfMotionPathPluginLoading = true;
|
|
447
441
|
const pluginScript = doc.createElement("script");
|
|
448
|
-
pluginScript.src =
|
|
442
|
+
pluginScript.src = motionPathPluginUrl(win.gsap?.version);
|
|
449
443
|
pluginScript.onload = () => {
|
|
450
444
|
win.__hfMotionPathPluginLoading = false;
|
|
451
445
|
executeScript();
|
|
@@ -34,6 +34,13 @@ export function isAudioTimelineElement(
|
|
|
34
34
|
return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** A track whose hide toggle reads as mute: the header button and its undo entry. */
|
|
38
|
+
export function isAudioOnlyTrack(
|
|
39
|
+
elements: readonly Pick<TimelineElement, "tag" | "src">[],
|
|
40
|
+
): boolean {
|
|
41
|
+
return elements.length > 0 && elements.every(isAudioTimelineElement);
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
/** The two tags the property panel lets you put a volume automation lane on.
|
|
38
45
|
* Single owner: `groupAutomationLanes`, `automationLaneCountOf` and
|
|
39
46
|
* `TimelineAutomationLaneSlot`'s clip filter all have to agree on this set. */
|
|
@@ -108,11 +115,9 @@ export function resolveBeatSourceTrack(
|
|
|
108
115
|
* May this multi-selection be hidden as one action?
|
|
109
116
|
*
|
|
110
117
|
* Audio has no visual to hide, and `data-hidden` on an audio element is what
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* write on `audioSelection`. The multi-selection "Hide all" was the one path
|
|
115
|
-
* left back to it, on a control whose label promises visibility.
|
|
118
|
+
* mutes it: preview silences it and the render drops it from the mix. The timeline
|
|
119
|
+
* and the single-selection panel offer it as a mute; "Hide all" would reach it on a
|
|
120
|
+
* control whose label promises visibility.
|
|
116
121
|
*
|
|
117
122
|
* A shared predicate rather than a check in the handler so the panel's button
|
|
118
123
|
* and the handler's refusal cannot disagree — the button is not the only caller.
|