@hyperframes/studio 0.8.117 → 0.8.118

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.
Files changed (31) hide show
  1. package/dist/assets/{hyperframes-player-5fcgSstJ.js → hyperframes-player-CnpIuNRl.js} +1 -1
  2. package/dist/assets/{index-C5qKdpgl.js → index-B4SbAADe.js} +189 -189
  3. package/dist/assets/{index-tg2LXUeu.js → index-BZ23-h1_.js} +1 -1
  4. package/dist/assets/{index-XJSawwYe.js → index-FeRU6G3G.js} +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2289 -2241
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/StudioRightPanels.tsx +3 -6
  11. package/src/components/TimelineToolbar.test.tsx +14 -0
  12. package/src/components/TimelineToolbar.tsx +4 -1
  13. package/src/components/editor/PropertyPanel.test.tsx +149 -25
  14. package/src/components/editor/PropertyPanel.tsx +1 -12
  15. package/src/components/editor/PropertyPanelEmptyState.test.tsx +2 -3
  16. package/src/components/editor/PropertyPanelEmptyState.tsx +3 -5
  17. package/src/components/editor/PropertyPanelFlat.tsx +9 -22
  18. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +7 -4
  19. package/src/components/editor/PropertyPanelFlatHeader.tsx +5 -3
  20. package/src/components/editor/usePanelHiddenToggle.ts +44 -0
  21. package/src/contexts/DomEditContext.tsx +3 -0
  22. package/src/hooks/timelineTrackVisibility.ts +7 -17
  23. package/src/player/components/Timeline.test.ts +48 -0
  24. package/src/player/components/TimelineTrackHeader.test.tsx +14 -22
  25. package/src/player/components/TimelineTrackHeader.tsx +4 -2
  26. package/src/player/components/TimelineTrackPlainHeader.test.tsx +17 -31
  27. package/src/player/components/TimelineTrackPlainHeader.tsx +9 -32
  28. package/src/player/components/hiddenToggle.tsx +20 -0
  29. package/src/utils/gsapSoftReload.test.ts +12 -2
  30. package/src/utils/gsapSoftReload.ts +4 -10
  31. 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
- // The visibility control is the old hide eye. On an audio track it silences
260
- // rather than hides, and the row already says so with a speaker elsewhere —
261
- // so the eye's slot stays empty there. A non-audio track is untouched.
262
- it("keeps the visibility control off audio track headers", () => {
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
- const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
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
- // The escape hatch. `data-hidden` on audio silences it in preview and drops it
279
- // from the render; the panel's "Muted" is the unrelated HTML `muted`
280
- // attribute, and nothing else writes it. Withholding the eye unconditionally
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
- const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
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
- // Audio: only while hidden — see the plain header.
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 visibility control's accessible contract.
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
- visible?: boolean;
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
- visible: props.visible ?? true,
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
- // `visible={false}` renders a SPACER, not nothing: every row's control
84
- // columns have to line up whether or not this row offers the control.
85
- it("holds the column with a spacer when withheld", () => {
86
- const view = renderButton({ hidden: false, visible: false, trackDisplayNumber: 2 });
87
- expect(view.host.querySelector("button")).toBeNull();
88
- expect(view.host.querySelector("span")).toBeTruthy();
89
- view.unmount();
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
- // A row that says what it is with a speaker does not also need the hide
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.querySelector("button")).toBeNull();
110
+ expect(labelOf(view.host)).toBe("Mute track 1");
121
111
  view.unmount();
122
112
  });
123
113
 
124
- // Withholding it unconditionally withheld the only way back: `data-hidden`
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("Show track 1");
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
- visible,
12
+ asMute,
24
13
  onToggle,
25
14
  }: {
26
15
  hidden: boolean;
27
16
  trackNumber: number;
28
17
  trackDisplayNumber: number | null;
29
- visible: boolean;
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 = visibilityButtonLabel(hidden, suffix);
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
- {visibilityButtonIcon(hidden)}
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
- visible={!isAudioTrack || isTrackHidden}
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 = MOTION_PATH_PLUGIN_CDN;
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 = MOTION_PATH_PLUGIN_CDN;
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
- * MUTES it — preview silences it and the render drops it from the mix. The
112
- * timeline withholds the eye on an audio track for that reason
113
- * (`visible={!isAudioTrack}`), and the single-selection panel gates the same
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.