@hyperframes/studio 0.8.7 → 0.8.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.8.7",
51
- "@hyperframes/player": "0.8.7",
52
- "@hyperframes/parsers": "0.8.7",
53
- "@hyperframes/sdk": "0.8.7",
54
- "@hyperframes/studio-server": "0.8.7"
50
+ "@hyperframes/core": "0.8.8",
51
+ "@hyperframes/parsers": "0.8.8",
52
+ "@hyperframes/player": "0.8.8",
53
+ "@hyperframes/studio-server": "0.8.8",
54
+ "@hyperframes/sdk": "0.8.8"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.8.7"
71
+ "@hyperframes/producer": "0.8.8"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
@@ -14,7 +14,6 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection";
14
14
  import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
15
15
  import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
16
16
  import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
17
- import { isCanaryEnabled } from "../../telemetry/canary";
18
17
  import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
19
18
  import { useVolumeAutomation } from "./useVolumeAutomation";
20
19
  import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
@@ -428,10 +427,7 @@ export function PropertyPanelFlat({
428
427
  });
429
428
  }
430
429
  }
431
- // Behind `audio-fx-rack`, at 0%. Gates the AUTHORING surface only: the runtime
432
- // and render still honour a `data-fx-chain` already on an element, so a
433
- // composition written through the skill does not go silently dry off-cohort.
434
- if (sections.audioFx && isCanaryEnabled("audio-fx-rack")) {
430
+ if (sections.audioFx) {
435
431
  groups.push({
436
432
  id: "audio-fx",
437
433
  title: "Audio FX",
@@ -223,6 +223,37 @@ describe("toggleTimelineTrackHidden", () => {
223
223
  expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 1");
224
224
  });
225
225
 
226
+ // The header derives its row from the GROUP-AWARE order (a synthetic anchor
227
+ // row, members pulled contiguous); this function's own fallback derives it
228
+ // from ascending element-bearing keys. Once a group exists those disagree, so
229
+ // the same click announced "Mute track 2" and recorded "Mute track 1". The
230
+ // clicked control passes the row it showed, and that is what gets recorded.
231
+ it("records the display row the clicked control announced, not its own derivation", async () => {
232
+ const files = new Map([
233
+ ["index.html", `<div id="voiceover" data-start="0" data-duration="2"></div>`],
234
+ ]);
235
+ stubProjectFiles(files);
236
+
237
+ const recordEdit = vi.fn();
238
+
239
+ await toggleTimelineTrackHidden({
240
+ projectId: "project-1",
241
+ activeCompPath: "index.html",
242
+ timelineElements: [element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio" })],
243
+ track: 0,
244
+ hidden: true,
245
+ // Row 2 in the header: the group's anchor row sits above this member.
246
+ displayNumber: 2,
247
+ previewIframe: null,
248
+ writeProjectFile: async () => {},
249
+ recordEdit,
250
+ domEditSaveTimestampRef: { current: 0 },
251
+ pendingTimelineEditPathRef: { current: new Set() },
252
+ });
253
+
254
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 2");
255
+ });
256
+
226
257
  it("labels unmuting an audio-only track back on", async () => {
227
258
  const files = new Map([
228
259
  ["index.html", `<div id="voiceover" data-start="0" data-duration="2" data-hidden=""></div>`],
@@ -32,6 +32,13 @@ interface ToggleTimelineTrackHiddenInput {
32
32
  timelineElements: readonly TimelineElement[];
33
33
  track: number;
34
34
  hidden: boolean;
35
+ /**
36
+ * The display row the clicked control announced, when the caller has one.
37
+ * Absent (a keyboard path or a programmatic call), the row is derived here
38
+ * from ascending element-bearing keys — correct whenever no group has
39
+ * reordered the header.
40
+ */
41
+ displayNumber?: number | null;
35
42
  previewIframe: HTMLIFrameElement | null;
36
43
  writeProjectFile: (path: string, content: string) => Promise<void>;
37
44
  recordEdit: (input: RecordEditInput) => Promise<void>;
@@ -209,6 +216,7 @@ export async function toggleTimelineTrackHidden({
209
216
  timelineElements,
210
217
  track,
211
218
  hidden,
219
+ displayNumber,
212
220
  previewIframe,
213
221
  writeProjectFile,
214
222
  recordEdit,
@@ -216,9 +224,12 @@ export async function toggleTimelineTrackHidden({
216
224
  pendingTimelineEditPathRef,
217
225
  }: ToggleTimelineTrackHiddenInput): Promise<string[]> {
218
226
  // `track` is the fractional sort key the callback needs; the history entry is
219
- // read by a human, so it gets the display row instead.
227
+ // read by a human, so it gets the display row instead — the one the clicked
228
+ // control announced, when the caller passed it. Deriving it again here would
229
+ // use ascending element-bearing keys, which stop matching the header as soon
230
+ // as an audio group reorders the rows and inserts an anchor.
220
231
  const suffix = trackDisplaySuffix(
221
- trackDisplayNumber(timelineTrackOrder(timelineElements), track),
232
+ displayNumber ?? trackDisplayNumber(timelineTrackOrder(timelineElements), track),
222
233
  );
223
234
  const trackElements = timelineElements.filter((element) => element.track === track);
224
235
  const isAudioOnlyTrack = trackElements.length > 0 && trackElements.every(isAudioTimelineElement);
@@ -397,14 +408,18 @@ export function useTimelineTrackVisibilityEditing({
397
408
  pendingTimelineEditPathRef,
398
409
  isRecordingRef,
399
410
  forceReloadSdkSession,
400
- }: UseTimelineTrackVisibilityEditingInput): (track: number, hidden: boolean) => Promise<void> {
411
+ }: UseTimelineTrackVisibilityEditingInput): (
412
+ track: number,
413
+ hidden: boolean,
414
+ displayNumber?: number | null,
415
+ ) => Promise<void> {
401
416
  // Resolve the eye toggle against the EXPANDED rows the canvas actually renders:
402
417
  // virtual sub-comp children carry their own (display.track + idx) track numbers,
403
418
  // so filtering the raw store list by a virtual track number would hide the wrong
404
419
  // outer-scene sibling sharing that index.
405
420
  const expandedElements = useExpandedTimelineElements();
406
421
  return useCallback(
407
- async (track: number, hidden: boolean) => {
422
+ async (track: number, hidden: boolean, displayNumber?: number | null) => {
408
423
  if (isRecordingRef?.current) {
409
424
  showToast("Cannot edit timeline while recording", "error");
410
425
  return;
@@ -418,6 +433,7 @@ export function useTimelineTrackVisibilityEditing({
418
433
  timelineElements: expandedElements,
419
434
  track,
420
435
  hidden,
436
+ displayNumber,
421
437
  previewIframe: previewIframeRef.current,
422
438
  writeProjectFile,
423
439
  recordEdit,
@@ -392,7 +392,7 @@ describe("Timeline provider boundary", () => {
392
392
  const row = button.parentElement?.parentElement;
393
393
  // Row children: [TimelineTrackHeader (sticky column), time-mapped content].
394
394
  const trackContent = row?.children.item(1);
395
- expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
395
+ expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false, 1);
396
396
  expect(trackContent).toBeInstanceOf(HTMLElement);
397
397
  if (!(trackContent instanceof HTMLElement)) {
398
398
  throw new Error("Expected track content element");
@@ -203,7 +203,9 @@ describe("TimelineLanes track numbering", () => {
203
203
  const second = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 2"]');
204
204
  act(() => second?.click());
205
205
 
206
- expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true);
206
+ // Third argument is the display row the button announced, so the undo entry
207
+ // records the same row the user just read off it.
208
+ expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true, 2);
207
209
  act(() => view.root.unmount());
208
210
  });
209
211
 
@@ -204,7 +204,8 @@ describe("TimelineTrackHeader", () => {
204
204
  expect(view.host.innerHTML).not.toContain("0.16666666666666666");
205
205
 
206
206
  act(() => eye?.click());
207
- expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true);
207
+ // The real fractional key acts; the display row rides along for the label.
208
+ expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true, 1);
208
209
  act(() => view.root.unmount());
209
210
  });
210
211
 
@@ -0,0 +1,161 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ /**
4
+ * The visibility control's accessible contract.
5
+ *
6
+ * The audio wording ran behind the `audio-track-mute` canary at 0%, so it had
7
+ * never rendered in any suite: it returned "Muted" / "Mute", which named the
8
+ * CURRENT state rather than the action, and dropped the track suffix so every
9
+ * audio row shared one accessible name. Music plus VO is the ordinary case, so
10
+ * that is two identical buttons. Pinned here because the label and the icon are
11
+ * the whole identity of this control.
12
+ */
13
+
14
+ import React, { act } from "react";
15
+ import { createRoot } from "react-dom/client";
16
+ import { afterEach, describe, expect, it, vi } from "vitest";
17
+ import { PlainTrackHeader, VisibilityButton } from "./TimelineTrackPlainHeader";
18
+
19
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
20
+
21
+ afterEach(() => {
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ function renderButton(props: {
26
+ hidden: boolean;
27
+ isAudioTrack?: boolean;
28
+ trackDisplayNumber: number | null;
29
+ }): { host: HTMLElement; unmount: () => void; onToggle: ReturnType<typeof vi.fn> } {
30
+ const host = document.createElement("div");
31
+ document.body.append(host);
32
+ const root = createRoot(host);
33
+ const onToggle = vi.fn();
34
+ act(() =>
35
+ root.render(
36
+ React.createElement(VisibilityButton, {
37
+ hidden: props.hidden,
38
+ trackNumber: 7,
39
+ trackDisplayNumber: props.trackDisplayNumber,
40
+ visible: true,
41
+ isAudioTrack: props.isAudioTrack,
42
+ onToggle,
43
+ }),
44
+ ),
45
+ );
46
+ return { host, unmount: () => act(() => root.unmount()), onToggle };
47
+ }
48
+
49
+ const labelOf = (host: HTMLElement) => host.querySelector("button")?.getAttribute("aria-label");
50
+
51
+ describe("VisibilityButton", () => {
52
+ it("names the action, not the state, on an audible audio track", () => {
53
+ const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
54
+ expect(labelOf(view.host)).toBe("Mute track 2");
55
+ view.unmount();
56
+ });
57
+
58
+ // The half that was wrong: a muted row read "Muted", so nothing told a
59
+ // screen-reader user that activating it would unmute.
60
+ it("promises the un-mute when the audio track is already muted", () => {
61
+ const view = renderButton({ hidden: true, isAudioTrack: true, trackDisplayNumber: 2 });
62
+ expect(labelOf(view.host)).toBe("Unmute track 2");
63
+ view.unmount();
64
+ });
65
+
66
+ it("keeps each audio row's name unique, so two tracks are distinguishable", () => {
67
+ const first = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 1 });
68
+ const second = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 3 });
69
+ expect(labelOf(first.host)).toBe("Mute track 1");
70
+ expect(labelOf(second.host)).toBe("Mute track 3");
71
+ expect(labelOf(first.host)).not.toBe(labelOf(second.host));
72
+ first.unmount();
73
+ second.unmount();
74
+ });
75
+
76
+ it("still says Hide/Show on a visual track", () => {
77
+ const shown = renderButton({ hidden: false, trackDisplayNumber: 2 });
78
+ expect(labelOf(shown.host)).toBe("Hide track 2");
79
+ shown.unmount();
80
+ const hiddenRow = renderButton({ hidden: true, trackDisplayNumber: 2 });
81
+ expect(labelOf(hiddenRow.host)).toBe("Show track 2");
82
+ hiddenRow.unmount();
83
+ });
84
+
85
+ // The callback acts on the REAL track key; the display row rides along so the
86
+ // undo-history label announces the same row this button just did, instead of
87
+ // re-deriving it from an ordering that has no group anchors in it.
88
+ it("toggles the real track number and passes the row it announced", () => {
89
+ const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
90
+ view.host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
91
+ expect(view.onToggle).toHaveBeenCalledWith(7, true, 2);
92
+ view.unmount();
93
+ });
94
+ });
95
+
96
+ describe("PlainTrackHeader", () => {
97
+ function renderHeader(overrides: Partial<Parameters<typeof PlainTrackHeader>[0]> = {}) {
98
+ const host = document.createElement("div");
99
+ document.body.append(host);
100
+ const root = createRoot(host);
101
+ const onToggleSolo = vi.fn();
102
+ act(() =>
103
+ root.render(
104
+ React.createElement(PlainTrackHeader, {
105
+ trackNumber: 0,
106
+ trackDisplayNumber: 1,
107
+ trackLabel: "Voiceover",
108
+ clipCount: 1,
109
+ isTrackHidden: false,
110
+ isAudioTrack: true,
111
+ onToggleTrackHidden: vi.fn(),
112
+ showTrackLabel: true,
113
+ isGroupMuted: false,
114
+ isSoloed: false,
115
+ onToggleSolo,
116
+ ...overrides,
117
+ }),
118
+ ),
119
+ );
120
+ return { host, unmount: () => act(() => root.unmount()), onToggleSolo };
121
+ }
122
+
123
+ const soloButton = (host: HTMLElement) =>
124
+ host.querySelector('button[aria-label="Hear only this"]');
125
+ const labelSpan = (host: HTMLElement) => host.querySelector("span.min-w-0");
126
+
127
+ it("offers solo on an audio track and reports its pressed state", () => {
128
+ const view = renderHeader({ isSoloed: true });
129
+ expect(soloButton(view.host)?.getAttribute("aria-pressed")).toBe("true");
130
+ view.unmount();
131
+ });
132
+
133
+ it("withholds solo from a visual track", () => {
134
+ const view = renderHeader({ isAudioTrack: false });
135
+ expect(soloButton(view.host)).toBeNull();
136
+ view.unmount();
137
+ });
138
+
139
+ // A group-muted member is silent without being hidden itself, so the strike
140
+ // is the only thing that says so — and the title has to explain why, since a
141
+ // user who never touched THIS row's mute is looking for the reason.
142
+ it("strikes the label through when the row's own mute is on", () => {
143
+ const view = renderHeader({ isTrackHidden: true });
144
+ expect(labelSpan(view.host)?.className).toContain("line-through");
145
+ view.unmount();
146
+ });
147
+
148
+ it("strikes it through for a group mute too, and says which", () => {
149
+ const view = renderHeader({ isGroupMuted: true });
150
+ expect(labelSpan(view.host)?.className).toContain("line-through");
151
+ expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover (group muted)");
152
+ view.unmount();
153
+ });
154
+
155
+ it("leaves an audible row unstruck", () => {
156
+ const view = renderHeader();
157
+ expect(labelSpan(view.host)?.className).not.toContain("line-through");
158
+ expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover");
159
+ view.unmount();
160
+ });
161
+ });
@@ -1,15 +1,24 @@
1
1
  import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
2
- import { isCanaryEnabled } from "../../telemetry/canary";
3
2
  import { Music } from "../../icons/SystemIcons";
4
3
  import { TimelineSoloButton } from "./TimelineSoloButton";
5
4
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
6
5
  import { TrackClipCount } from "./TrackClipCount";
7
6
  import { trackDisplaySuffix } from "./timelineTrackDisplay";
8
7
 
9
- // Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows.
10
- // Gated: the relabel ships behind the canary, unlike the preview fix.
8
+ /**
9
+ * Audio tracks say "Mute", not "Hide" the eye IS mute for sound-only rows.
10
+ *
11
+ * Action-phrased and per-track, like the visual branch: `hidden` here is the
12
+ * CURRENT state, so the name has to promise the opposite. It read "Muted" /
13
+ * "Mute" until the rollout, which named the state instead of the action (a
14
+ * screen-reader user could not tell that activating an already-muted row would
15
+ * unmute it) and dropped `suffix`, so every audio row shared one accessible
16
+ * name — music plus VO being the ordinary case. The wording matches the undo
17
+ * entry `timelineTrackVisibility` writes for the same click, where `hidden` is
18
+ * the INCOMING state and the two therefore read inverted.
19
+ */
11
20
  function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
12
- if (showAsMute) return hidden ? "Muted" : "Mute";
21
+ if (showAsMute) return hidden ? `Unmute track${suffix}` : `Mute track${suffix}`;
13
22
  return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
14
23
  }
15
24
 
@@ -37,7 +46,7 @@ export function VisibilityButton({
37
46
  // Display number in the text, real key in the callback. The two must not be
38
47
  // conflated in either direction.
39
48
  const suffix = trackDisplaySuffix(trackDisplayNumber);
40
- const showAsMute = Boolean(isAudioTrack) && isCanaryEnabled("audio-track-mute");
49
+ const showAsMute = Boolean(isAudioTrack);
41
50
  const label = visibilityButtonLabel(showAsMute, hidden, suffix);
42
51
  return (
43
52
  <button
@@ -50,7 +59,7 @@ export function VisibilityButton({
50
59
  onPointerDown={(event) => event.stopPropagation()}
51
60
  onClick={(event) => {
52
61
  event.stopPropagation();
53
- void onToggle?.(trackNumber, !hidden);
62
+ void onToggle?.(trackNumber, !hidden, trackDisplayNumber);
54
63
  }}
55
64
  >
56
65
  {visibilityButtonIcon(showAsMute, hidden)}
@@ -93,9 +102,7 @@ export function PlainTrackHeader({
93
102
  {showTrackLabel && (
94
103
  <span
95
104
  className={`min-w-0 flex-1 truncate text-[11px] ${
96
- isAudioTrack && (isTrackHidden || isGroupMuted) && isCanaryEnabled("audio-track-mute")
97
- ? "line-through"
98
- : ""
105
+ isAudioTrack && (isTrackHidden || isGroupMuted) ? "line-through" : ""
99
106
  }`}
100
107
  title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
101
108
  >
@@ -111,7 +118,7 @@ export function PlainTrackHeader({
111
118
  isAudioTrack={isAudioTrack}
112
119
  onToggle={onToggleTrackHidden}
113
120
  />
114
- {isAudioTrack && isCanaryEnabled("audio-track-mute") && onToggleSolo && (
121
+ {isAudioTrack && onToggleSolo && (
115
122
  <TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
116
123
  )}
117
124
  </>
@@ -67,7 +67,20 @@ export interface TimelineEditCallbacks {
67
67
  }>,
68
68
  options?: { coalesceKey?: string },
69
69
  ) => Promise<void> | void;
70
- onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
70
+ /**
71
+ * `displayNumber` is the row the CLICKED control announced. It travels with
72
+ * the click because the header and the undo-history label derive the row from
73
+ * two different orderings: the header's comes from the group-aware row list
74
+ * (synthetic anchor rows, members pulled contiguous), the history's from a
75
+ * plain ascending sort of element-bearing keys. Once a group exists those
76
+ * disagree, so the same click said "Mute track 2" and recorded "Mute track 1".
77
+ * Passing the rendered number keeps one answer instead of two derivations.
78
+ */
79
+ onToggleTrackHidden?: (
80
+ track: number,
81
+ hidden: boolean,
82
+ displayNumber?: number | null,
83
+ ) => Promise<void> | void;
71
84
  /** B7's bus strip: live-write the group's own attribute while dragging. */
72
85
  onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
73
86
  /** ...and persist one undo entry on release. */
@@ -0,0 +1,103 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ /**
4
+ * Row derivation for audio groups.
5
+ *
6
+ * This ran behind the `audio-groups` canary at 0% until the rollout, so it had
7
+ * never executed in the enabled state in any suite — the off-cohort branch
8
+ * returned raw tracks and stopped. Now it reorders rows and emits synthetic
9
+ * anchor rows for every user, which is exactly the pair of invariants pinned
10
+ * here: an ungrouped project is untouched, and a group's members become
11
+ * contiguous under an anchor at `memberTracks[0] - 0.5`.
12
+ */
13
+
14
+ import React, { act } from "react";
15
+ import { createRoot } from "react-dom/client";
16
+ import { afterEach, describe, expect, it } from "vitest";
17
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
18
+ import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
19
+
20
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
21
+
22
+ afterEach(() => {
23
+ usePlayerStore.getState().reset();
24
+ });
25
+
26
+ function clip(id: string, track: number, extra: Partial<TimelineElement> = {}): TimelineElement {
27
+ return { id, label: id, tag: "audio", start: 0, duration: 1, track, ...extra };
28
+ }
29
+
30
+ function derive(elements: TimelineElement[]): ReturnType<typeof useTimelineTrackDerivations> {
31
+ let result: ReturnType<typeof useTimelineTrackDerivations> | undefined;
32
+ function Probe() {
33
+ result = useTimelineTrackDerivations(elements);
34
+ return null;
35
+ }
36
+ const root = createRoot(document.createElement("div"));
37
+ act(() => root.render(React.createElement(Probe)));
38
+ act(() => root.unmount());
39
+ if (!result) throw new Error("derivations did not render");
40
+ return result;
41
+ }
42
+
43
+ describe("useTimelineTrackDerivations", () => {
44
+ it("leaves an ungrouped project in raw ascending order", () => {
45
+ const { tracks, groups, trackGroupOf } = derive([clip("b", 2), clip("a", 0), clip("c", 1)]);
46
+
47
+ expect(tracks.map(([track]) => track)).toEqual([0, 1, 2]);
48
+ expect(tracks.map(([, els]) => els.map((el) => el.id))).toEqual([["a"], ["c"], ["b"]]);
49
+ expect(groups).toEqual([]);
50
+ expect(trackGroupOf.size).toBe(0);
51
+ });
52
+
53
+ // The reordering case: track 1 sits between the group's two members in raw
54
+ // order and must not be dragged into the group.
55
+ it("pulls interleaved members contiguous under a synthetic anchor row", () => {
56
+ const { tracks, groups, trackGroupOf } = derive([
57
+ clip("vo-1", 0, { audioGroup: "voiceover", audioGroupLabel: "Voiceover" }),
58
+ clip("music", 1),
59
+ clip("vo-2", 2, { audioGroup: "voiceover" }),
60
+ ]);
61
+
62
+ // Anchor immediately above the lowest member, then both members, then the
63
+ // ungrouped track keeps its own position after them.
64
+ expect(tracks.map(([track]) => track)).toEqual([-0.5, 0, 2, 1]);
65
+ // The anchor row owns no clips of its own.
66
+ expect(tracks[0]?.[1]).toEqual([]);
67
+
68
+ expect(groups).toHaveLength(1);
69
+ expect(groups[0]).toMatchObject({
70
+ id: "voiceover",
71
+ label: "Voiceover",
72
+ anchorKey: -0.5,
73
+ memberTracks: [0, 2],
74
+ });
75
+
76
+ expect(trackGroupOf.get(0)?.id).toBe("voiceover");
77
+ expect(trackGroupOf.get(2)?.id).toBe("voiceover");
78
+ expect(trackGroupOf.has(1)).toBe(false);
79
+ });
80
+
81
+ it("carries the group element's own label, volume and mute onto the row", () => {
82
+ const { groups } = derive([
83
+ clip("vo-1", 3, {
84
+ audioGroup: "vo",
85
+ audioGroupLabel: "VO bus",
86
+ audioGroupVolume: 0.5,
87
+ audioGroupHidden: true,
88
+ }),
89
+ ]);
90
+
91
+ expect(groups[0]).toMatchObject({
92
+ label: "VO bus",
93
+ anchorKey: 2.5,
94
+ volume: 0.5,
95
+ hidden: true,
96
+ });
97
+ });
98
+
99
+ it("falls back to the group id and unity volume when the bus carries neither", () => {
100
+ const { groups } = derive([clip("sfx-1", 0, { audioGroup: "sfx" })]);
101
+ expect(groups[0]).toMatchObject({ id: "sfx", label: "sfx", volume: 1, hidden: false });
102
+ });
103
+ });
@@ -1,6 +1,5 @@
1
1
  import { useMemo } from "react";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
- import { isCanaryEnabled } from "../../telemetry/canary";
4
3
  import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
5
4
 
6
5
  /** One resolved audio group, positioned in the row order. */
@@ -157,16 +156,10 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
157
156
  return Array.from(map.entries()).sort(([a], [b]) => a - b);
158
157
  }, [expandedElements]);
159
158
 
160
- const { tracks, groups, trackGroupOf } = useMemo(() => {
161
- if (!isCanaryEnabled("audio-groups")) {
162
- return {
163
- tracks: rawTracks,
164
- groups: [],
165
- trackGroupOf: new Map<number, TimelineTrackGroupInfo>(),
166
- };
167
- }
168
- return groupTimelineTracks(rawTracks);
169
- }, [rawTracks]);
159
+ const { tracks, groups, trackGroupOf } = useMemo(
160
+ () => groupTimelineTracks(rawTracks),
161
+ [rawTracks],
162
+ );
170
163
 
171
164
  const trackStyles = useMemo(() => {
172
165
  const map = new Map<number, TrackVisualStyle>();