@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/dist/assets/{hyperframes-player-bDcU464i.js → hyperframes-player-DYTqZd0d.js} +1 -1
- package/dist/assets/{index-FkAs3zRu.js → index-B69X8UsY.js} +1 -1
- package/dist/assets/{index-MSOSP1-J.js → index-D6CV2x61.js} +1 -1
- package/dist/assets/{index-CUXnyoIa.js → index-ng4tq6YE.js} +115 -115
- package/dist/index.d.ts +10 -1
- package/dist/index.html +1 -1
- package/dist/index.js +14 -21
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/PropertyPanelFlat.tsx +1 -5
- package/src/hooks/timelineTrackVisibility.test.ts +31 -0
- package/src/hooks/timelineTrackVisibility.ts +20 -4
- package/src/player/components/Timeline.test.ts +1 -1
- package/src/player/components/TimelineLanes.test.tsx +3 -1
- package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +17 -10
- package/src/player/components/timelineCallbacks.ts +14 -1
- package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
- package/src/player/components/useTimelineTrackDerivations.ts +4 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
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.
|
|
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
|
-
|
|
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): (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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 ?
|
|
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)
|
|
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)
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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>();
|