@hyperframes/studio 0.8.7 → 0.8.9

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-bDcU464i.js → hyperframes-player-OP68oEBG.js} +1 -1
  2. package/dist/assets/{index-CUXnyoIa.js → index-57oMsXQN.js} +111 -111
  3. package/dist/assets/{index-MSOSP1-J.js → index-BIdEpMGS.js} +1 -1
  4. package/dist/assets/{index-FkAs3zRu.js → index-D1Sa69m2.js} +1 -1
  5. package/dist/index.d.ts +10 -1
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +40 -35
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/editor/CanvasContextMenu.test.tsx +1 -1
  11. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  12. package/src/components/editor/PropertyPanelFlat.tsx +1 -5
  13. package/src/components/editor/TimelineFxPopover.test.tsx +89 -0
  14. package/src/components/editor/TimelineFxPopover.tsx +49 -15
  15. package/src/hooks/timelineTrackVisibility.test.ts +31 -0
  16. package/src/hooks/timelineTrackVisibility.ts +20 -4
  17. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  18. package/src/player/components/ClipContextMenu.tsx +1 -1
  19. package/src/player/components/KeyframeDiamondContextMenu.tsx +1 -1
  20. package/src/player/components/Timeline.test.ts +1 -1
  21. package/src/player/components/TimelineFxButton.tsx +1 -1
  22. package/src/player/components/TimelineGroupHeader.test.tsx +76 -0
  23. package/src/player/components/TimelineGroupHeader.tsx +8 -4
  24. package/src/player/components/TimelineLanes.test.tsx +3 -1
  25. package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
  26. package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
  27. package/src/player/components/TimelineTrackPlainHeader.tsx +17 -10
  28. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  29. package/src/player/components/timelineCallbacks.ts +14 -1
  30. package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
  31. package/src/player/components/useTimelineTrackDerivations.ts +4 -11
@@ -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
  </>
@@ -75,7 +75,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
75
75
  return createPortal(
76
76
  <div
77
77
  ref={menuRef}
78
- className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
78
+ className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
79
79
  style={{ left: adjustedX, top: adjustedY }}
80
80
  onPointerLeave={() => onHoverAction(null)}
81
81
  >
@@ -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>();