mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio,core): mute groups, and hear-only-this that cannot reach the export
B5: mute and solo, on groups and tracks (track mute already shipped by A2 —
nothing to build there).
Group mute — persisted as data-hidden on the <hf-audio-group> element itself
(never written onto members, per design doc §2.1's state-restoration
warning). Studio action reuses B7's generic setAudioGroupAttribute
(setQuiet/setLive split) rather than duplicating toggleTimelineTrackHidden's
shape — same one-atomic-patch/one-undo-entry contract, already built for
exactly this purpose. Render: B4 already drops every member of a
data-hidden group (confirmed by a new audioMixer.test.ts case — no
production change needed there). Preview: a dedicated muteGain node
(groupInput -> [fx] -> muteGain -> output -> master) so a mute toggle
never fights scheduleVolumeLane's ramps on the same param — the same
hazard B7's volume fader was split out to avoid. Mid-playback toggles
sync via a new syncAudioGroupMute pass in init.ts (a group carries no
data-start, so it's invisible to the existing visibility-node query).
Members of a muted group render the strikethrough label treatment
(TimelineTrackPlainHeader's isGroupMuted, sourced from
TimelineElement.audioGroupHidden) — display only, no attribute touched.
Solo — "Hear only this": a new session-only store slice (audioSoloSlice,
soloed: ReadonlySet<string> of clip/group ids, never track numbers, never
serialized). Predicate (isAudibleUnderSolo, packages/core/src/audioGroups.ts
so both the store and the preview transport share one definition): an
element is audible while any solo is active only if it or its own group is
soloed. "Siblings, never ancestors" lives in the graph, not the predicate —
solo gain is a per-element stage only; group buses are never attenuated by
solo, so a soloed member's path through its group stays open by
construction. Preview: a dedicated per-element soloGain in
webAudioTransport.ts (parallel to the mute mechanics), pushed via
window.__hf.setAudioSolo — a direct call, not an attribute write, so it
can't ride the visibility-diff path mute uses. media.ts's HTMLMedia
fallback folds the same predicate into its per-tick volume computation
(the same seam A2 used for data-hidden). Half-lit group indicator
(isGroupHalfLitUnderSolo) for "not soloed itself, but a member is".
Exclusive-by-default toggle, ⌘/Ctrl-click to add/remove, TimelineSoloButton
(⌗) beside mute on both track and group headers. Transport-bar banner
("Hearing only <label> — your export is not affected", Clear button) added
in PlayerControls.tsx, reading labels straight off the live preview DOM.
Export-safety, the most important property here: toggling/adding/clearing
solo never calls setAttribute/removeAttribute on any element and never
invokes the project save path (both asserted directly via spies in
audioSoloSlice.test.ts) — solo cannot reach an export by construction, not
by convention.
Also: extracted useHydrateActiveCompPathFromUrl out of App.tsx (a
pre-existing, unrelated effect) to stay under the 600-line filesize cap
after wiring useAudioSoloBridge in; and fixed a circular dependency the
solo-banner wiring introduced (useAudioSoloBridge.ts now imports
usePlayerStore from its concrete module instead of the player/ barrel,
which re-exports PlayerControls.tsx — the barrel path is what closed the
cycle).
Gates: bun run build clean; packages/core full suite 2379/2379; packages/
studio full suite 4276/4294 (18 pre-existing todo); packages/engine
audioMixer.grouping.test.ts 5/5; oxfmt/oxlint clean on all 23 touched
files; fallow clean (0 new circular deps, 0 new filesize/complexity
findings).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a11883e6d1
commit
ba1d807621
@@ -127,3 +127,35 @@ export function audioGroupOf(el: Element): string | null {
|
|||||||
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return null;
|
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return null;
|
||||||
return typeof el.getAttribute === "function" ? el.getAttribute(HF_AUDIO_GROUP_ATTR) : null;
|
return typeof el.getAttribute === "function" ? el.getAttribute(HF_AUDIO_GROUP_ATTR) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solo ("Hear only this") predicate — shared by the studio store (which owns
|
||||||
|
* the `soloed` set and the UI's lit/half-lit state) and the preview transport
|
||||||
|
* (which turns it into gain). An element is audible while any solo is active
|
||||||
|
* only if IT is soloed, or its OWN group is soloed (group solo = members
|
||||||
|
* solo). There is no "ancestor" to reach up to in this data model — a group
|
||||||
|
* bus is never itself attenuated by solo, so a soloed member's path through
|
||||||
|
* its group stays open by construction; this predicate only ever gates the
|
||||||
|
* member's own gain. No solo active at all is the one path that returns true
|
||||||
|
* unconditionally.
|
||||||
|
*/
|
||||||
|
export function isAudibleUnderSolo(
|
||||||
|
soloed: ReadonlySet<string>,
|
||||||
|
id: string,
|
||||||
|
groupId?: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (soloed.size === 0) return true;
|
||||||
|
if (soloed.has(id)) return true;
|
||||||
|
return Boolean(groupId && soloed.has(groupId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half-lit: this group itself isn't soloed, but at least one of its members
|
||||||
|
* is — the display-only signal that "some of what's under here still plays". */
|
||||||
|
export function isGroupHalfLitUnderSolo(
|
||||||
|
soloed: ReadonlySet<string>,
|
||||||
|
groupId: string,
|
||||||
|
memberIds: readonly string[],
|
||||||
|
): boolean {
|
||||||
|
if (soloed.size === 0 || soloed.has(groupId)) return false;
|
||||||
|
return memberIds.some((id) => soloed.has(id));
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { applyVariableBindings } from "./applyVariableBindings";
|
|||||||
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
|
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
|
||||||
import { TransportClock } from "./clock";
|
import { TransportClock } from "./clock";
|
||||||
import { WebAudioTransport } from "./webAudioTransport";
|
import { WebAudioTransport } from "./webAudioTransport";
|
||||||
|
import { HF_AUDIO_GROUP_TAG, audioGroupOf, isAudibleUnderSolo } from "../audioGroups";
|
||||||
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
|
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
|
||||||
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
|
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
|
||||||
import type {
|
import type {
|
||||||
@@ -177,6 +178,20 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
void webAudio.init().then((ok) => {
|
void webAudio.init().then((ok) => {
|
||||||
webAudioReady = ok;
|
webAudioReady = ok;
|
||||||
});
|
});
|
||||||
|
// Studio's "Hear only this" push channel — session-only, so it rides a
|
||||||
|
// dedicated `__hf` field (mirrors `colorGrading`'s lazy-init pattern) rather
|
||||||
|
// than a DOM attribute: solo must never be written to the document (design
|
||||||
|
// doc §2.2 / the export-safety guarantee), so there is nothing here for
|
||||||
|
// `syncTimedElementVisibility`'s attribute-diffing to key off. Kept in this
|
||||||
|
// closure too (not just inside `webAudio`) so `syncRuntimeMedia`'s
|
||||||
|
// HTMLMedia-fallback path (video/non-transport audio) can apply the same
|
||||||
|
// predicate per tick, the same split A2 used for `data-hidden`.
|
||||||
|
let soloedIds: ReadonlySet<string> = new Set();
|
||||||
|
window.__hf = window.__hf || {};
|
||||||
|
window.__hf.setAudioSolo = (ids) => {
|
||||||
|
soloedIds = new Set(ids);
|
||||||
|
webAudio.setSolo(soloedIds);
|
||||||
|
};
|
||||||
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
|
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
|
||||||
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
|
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
|
||||||
// plugin so GSAP doesn't log "Invalid property _auto" on every tween build —
|
// plugin so GSAP doesn't log "Invalid property _auto" on every tween build —
|
||||||
@@ -1924,6 +1939,21 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
const nodeAffectsAudio = (node: HTMLElement): boolean =>
|
const nodeAffectsAudio = (node: HTMLElement): boolean =>
|
||||||
node.matches("audio[data-start]") || node.querySelector("audio[data-start]") !== null;
|
node.matches("audio[data-start]") || node.querySelector("audio[data-start]") !== null;
|
||||||
|
|
||||||
|
// An `<hf-audio-group>` carries no `data-start`, so it is never among
|
||||||
|
// `visibilityNodes` above — group mute needs its own small diff pass.
|
||||||
|
// Preview-side only (render reads the group's `data-hidden` directly at
|
||||||
|
// export time, per B4); this just keeps the live WebAudio group bus in
|
||||||
|
// sync with a `data-hidden` toggle made mid-playback.
|
||||||
|
const groupHiddenLast = new WeakMap<Element, boolean>();
|
||||||
|
const syncAudioGroupMute = () => {
|
||||||
|
for (const groupEl of document.querySelectorAll(HF_AUDIO_GROUP_TAG)) {
|
||||||
|
const hidden = groupEl.hasAttribute("data-hidden");
|
||||||
|
if (groupHiddenLast.get(groupEl) === hidden) continue;
|
||||||
|
groupHiddenLast.set(groupEl, hidden);
|
||||||
|
if (groupEl.id) webAudio.setGroupMuted(groupEl.id, hidden);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const syncTimedElementVisibility = (
|
const syncTimedElementVisibility = (
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
visibilityNodes: Element[] = Array.from(document.querySelectorAll("[data-start]")),
|
visibilityNodes: Element[] = Array.from(document.querySelectorAll("[data-start]")),
|
||||||
@@ -1988,6 +2018,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
scheduleWebAudioForActiveClips();
|
scheduleWebAudioForActiveClips();
|
||||||
}
|
}
|
||||||
hiddenAudioDirty = false;
|
hiddenAudioDirty = false;
|
||||||
|
syncAudioGroupMute();
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncMediaForCurrentState = () => {
|
const syncMediaForCurrentState = () => {
|
||||||
@@ -2048,6 +2079,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
webAudio.setElementVolume(el, authorVolume),
|
webAudio.setElementVolume(el, authorVolume),
|
||||||
isWebAudioOwned: (el) => webAudio.ownsElement(el),
|
isWebAudioOwned: (el) => webAudio.ownsElement(el),
|
||||||
isWebAudioRouted: (el) => webAudio.routesElement(el),
|
isWebAudioRouted: (el) => webAudio.routesElement(el),
|
||||||
|
isAudibleUnderSolo: (el) => isAudibleUnderSolo(soloedIds, el.id, audioGroupOf(el)),
|
||||||
onAutoplayBlocked: () => {
|
onAutoplayBlocked: () => {
|
||||||
if (state.mediaAutoplayBlockedPosted) return;
|
if (state.mediaAutoplayBlockedPosted) return;
|
||||||
state.mediaAutoplayBlockedPosted = true;
|
state.mediaAutoplayBlockedPosted = true;
|
||||||
|
|||||||
@@ -217,6 +217,11 @@ export function syncRuntimeMedia(params: {
|
|||||||
/** Native media routed through WebAudio keeps its upstream element volume at
|
/** Native media routed through WebAudio keeps its upstream element volume at
|
||||||
* unity; do not mistake that transport write for an authored volume edit. */
|
* unity; do not mistake that transport write for an authored volume edit. */
|
||||||
isWebAudioRouted?: (el: HTMLMediaElement) => boolean;
|
isWebAudioRouted?: (el: HTMLMediaElement) => boolean;
|
||||||
|
/** "Hear only this" gate for the HTMLMedia fallback path (video / any audio
|
||||||
|
* not owned by the Web Audio transport, which applies its own dedicated
|
||||||
|
* solo gain instead — see `WebAudioTransport.setSolo`). Absent when solo
|
||||||
|
* isn't wired up at all, which reads as "always audible". */
|
||||||
|
isAudibleUnderSolo?: (el: HTMLMediaElement) => boolean;
|
||||||
forceSync?: boolean;
|
forceSync?: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
const forceMuteAll = !!(params.outputMuted || params.userMuted);
|
const forceMuteAll = !!(params.outputMuted || params.userMuted);
|
||||||
@@ -318,7 +323,11 @@ export function syncRuntimeMedia(params: {
|
|||||||
// A data-hidden ancestor is silent in the export (audioMixer.ts drops
|
// A data-hidden ancestor is silent in the export (audioMixer.ts drops
|
||||||
// it); preview must match. Folded into the per-tick volume, not
|
// it); preview must match. Folded into the per-tick volume, not
|
||||||
// el.muted (RULES trap: el.muted is the transport's ownership flag).
|
// el.muted (RULES trap: el.muted is the transport's ownership flag).
|
||||||
const effectiveVolume = el.closest("[data-hidden]") ? 0 : clampVolume(authorVolume * userVol);
|
// Solo rides the same fold for the same reason — never el.muted, and
|
||||||
|
// never touching any attribute (it is session-only, unlike hidden).
|
||||||
|
const silencedBySolo = params.isAudibleUnderSolo ? !params.isAudibleUnderSolo(el) : false;
|
||||||
|
const effectiveVolume =
|
||||||
|
el.closest("[data-hidden]") || silencedBySolo ? 0 : clampVolume(authorVolume * userVol);
|
||||||
el.volume = effectiveVolume;
|
el.volume = effectiveVolume;
|
||||||
lastRuntimeAppliedVolume.set(el, effectiveVolume);
|
lastRuntimeAppliedVolume.set(el, effectiveVolume);
|
||||||
params.onElementVolume?.(el, effectiveVolume, authorVolume);
|
params.onElementVolume?.(el, effectiveVolume, authorVolume);
|
||||||
|
|||||||
@@ -697,23 +697,26 @@ describe("WebAudioTransport", () => {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The group's own input gain is built lazily on the first member —
|
/** The group's own input gain is built lazily on the first member — index
|
||||||
* index 1 in creation order (that member's gain is index 0). */
|
* 2 in creation order (that member's own gain is 0, its solo gain 1). */
|
||||||
const firstGroupInput = (mock: ReturnType<typeof createGroupMockAudioContext>) =>
|
const firstGroupInput = (mock: ReturnType<typeof createGroupMockAudioContext>) =>
|
||||||
mock.gainNodes[1]!;
|
mock.gainNodes[2]!;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes an ungrouped member straight to master, unchanged", async () => {
|
it("routes an ungrouped member straight to master, through its own solo gain", async () => {
|
||||||
const { transport, mock, gen } = setupGroupTransport();
|
const { transport, mock, gen } = setupGroupTransport();
|
||||||
|
|
||||||
await scheduleGrouped(transport, gen, "solo");
|
await scheduleGrouped(transport, gen, "solo");
|
||||||
|
|
||||||
// One gain node — the member's own — connected directly to master.
|
// Member gain, then its dedicated solo gain (B5) — never straight to master.
|
||||||
expect(mock.gainNodes).toHaveLength(1);
|
expect(mock.gainNodes).toHaveLength(2);
|
||||||
expect(mock.gainNodes[0]!.connect).toHaveBeenCalledWith(mock.masterGain);
|
const [memberGain, soloGain] = mock.gainNodes;
|
||||||
|
expect(memberGain!.connect).toHaveBeenCalledWith(soloGain);
|
||||||
|
expect(memberGain!.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||||
|
expect(soloGain!.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("two members of the same group land on ONE shared group gain, not master directly", async () => {
|
it("two members of the same group land on ONE shared group gain, not master directly", async () => {
|
||||||
@@ -722,26 +725,33 @@ describe("WebAudioTransport", () => {
|
|||||||
await scheduleGrouped(transport, gen, "a", "vo");
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
await scheduleGrouped(transport, gen, "b", "vo");
|
await scheduleGrouped(transport, gen, "b", "vo");
|
||||||
|
|
||||||
// Member gain nodes: index 0 (a) and index 3 (b) — index 1/2 are the
|
// Creation order for a: a-gain(0), a-solo(1), groupInput(2), groupOutput(3),
|
||||||
// group's own input/output gain pair (B7's meter taps `output`),
|
// muteGain(4) — the group bus is built lazily inside a's schedule call.
|
||||||
// built inside a's schedule call.
|
// Then b: b-gain(5), b-solo(6).
|
||||||
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(4);
|
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(7);
|
||||||
const groupInput = firstGroupInput(mock);
|
|
||||||
const groupOutput = mock.gainNodes[2]!;
|
|
||||||
const aGain = mock.gainNodes[0]!;
|
const aGain = mock.gainNodes[0]!;
|
||||||
const bGain = mock.gainNodes[3]!;
|
const aSolo = mock.gainNodes[1]!;
|
||||||
|
const groupInput = firstGroupInput(mock);
|
||||||
|
const groupOutput = mock.gainNodes[3]!;
|
||||||
|
const muteGain = mock.gainNodes[4]!;
|
||||||
|
const bGain = mock.gainNodes[5]!;
|
||||||
|
const bSolo = mock.gainNodes[6]!;
|
||||||
|
|
||||||
// Neither member connects straight to master — both feed the shared bus.
|
// Each member feeds its own solo gain, and both solo gains feed the
|
||||||
expect(aGain.connect).toHaveBeenCalledWith(groupInput);
|
// shared bus — neither connects straight to master.
|
||||||
expect(bGain.connect).toHaveBeenCalledWith(groupInput);
|
expect(aGain.connect).toHaveBeenCalledWith(aSolo);
|
||||||
expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
expect(bGain.connect).toHaveBeenCalledWith(bSolo);
|
||||||
expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
expect(aSolo.connect).toHaveBeenCalledWith(groupInput);
|
||||||
|
expect(bSolo.connect).toHaveBeenCalledWith(groupInput);
|
||||||
|
expect(aSolo.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||||
|
expect(bSolo.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||||
|
|
||||||
// The bus's input never reaches master directly — it lands on the
|
// The bus's input never reaches master directly — it lands on the mute
|
||||||
// output gain (the dry passthrough, since neither member's group has a
|
// gain (B5) first (the dry passthrough, since neither member's group has
|
||||||
// chain-bearing `<hf-audio-group>`), and THAT reaches master.
|
// a chain-bearing `<hf-audio-group>`), then the output gain, then master.
|
||||||
expect(groupInput.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
expect(groupInput.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||||
expect(groupInput.connect).toHaveBeenCalledWith(groupOutput);
|
expect(groupInput.connect).toHaveBeenCalledWith(muteGain);
|
||||||
|
expect(muteGain.connect).toHaveBeenCalledWith(groupOutput);
|
||||||
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
|
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -749,11 +759,11 @@ describe("WebAudioTransport", () => {
|
|||||||
const { transport, mock, gen } = setupGroupTransport();
|
const { transport, mock, gen } = setupGroupTransport();
|
||||||
|
|
||||||
await scheduleGrouped(transport, gen, "a", "vo");
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
const gainCountAfterFirst = mock.gainNodes.length; // a-gain + group-input
|
const gainCountAfterFirst = mock.gainNodes.length; // a-gain + a-solo + group-input/output/mute
|
||||||
await scheduleGrouped(transport, gen, "b", "vo");
|
await scheduleGrouped(transport, gen, "b", "vo");
|
||||||
|
|
||||||
// Only b's own gain is new — no second group-input gain minted.
|
// Only b's own gain and its solo gain are new — no second group bus minted.
|
||||||
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 1);
|
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a group id with no matching <hf-audio-group> element still gets a flat bus", async () => {
|
it("a group id with no matching <hf-audio-group> element still gets a flat bus", async () => {
|
||||||
@@ -761,8 +771,10 @@ describe("WebAudioTransport", () => {
|
|||||||
|
|
||||||
await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element
|
await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element
|
||||||
|
|
||||||
const groupOutput = mock.gainNodes[2]!;
|
const muteGain = mock.gainNodes[4]!;
|
||||||
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(groupOutput);
|
const groupOutput = mock.gainNodes[3]!;
|
||||||
|
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(muteGain);
|
||||||
|
expect(muteGain.connect).toHaveBeenCalledWith(groupOutput);
|
||||||
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
|
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -799,6 +811,106 @@ describe("WebAudioTransport", () => {
|
|||||||
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
|
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('solo — "Hear only this" (B5)', () => {
|
||||||
|
it("silences a non-soloed member via its own solo gain, without touching the group bus", async () => {
|
||||||
|
const { transport, mock, gen } = setupGroupTransport();
|
||||||
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
|
await scheduleGrouped(transport, gen, "b", "vo");
|
||||||
|
const aSolo = mock.gainNodes[1]!;
|
||||||
|
const bSolo = mock.gainNodes[6]!;
|
||||||
|
const groupInput = firstGroupInput(mock);
|
||||||
|
|
||||||
|
transport.setSolo(new Set(["other-clip"]));
|
||||||
|
|
||||||
|
expect(aSolo.gain.value).toBe(0);
|
||||||
|
expect(bSolo.gain.value).toBe(0);
|
||||||
|
// The group's own bus is never attenuated by solo — only the member
|
||||||
|
// gain stage is (design doc §2.2: "never ancestors").
|
||||||
|
expect(groupInput.gain.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("soloing a member of a group leaves the group's gain untouched, and only that member is audible", async () => {
|
||||||
|
const { transport, mock, gen } = setupGroupTransport();
|
||||||
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
|
await scheduleGrouped(transport, gen, "b", "vo");
|
||||||
|
const aSolo = mock.gainNodes[1]!;
|
||||||
|
const bSolo = mock.gainNodes[6]!;
|
||||||
|
const groupInput = firstGroupInput(mock);
|
||||||
|
|
||||||
|
transport.setSolo(new Set(["a"]));
|
||||||
|
|
||||||
|
expect(aSolo.gain.value).toBe(1);
|
||||||
|
expect(bSolo.gain.value).toBe(0); // sibling stays silent
|
||||||
|
expect(groupInput.gain.value).toBe(1); // group bus itself untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it("soloing the GROUP id makes every member audible (group solo = members solo)", async () => {
|
||||||
|
const { transport, mock, gen } = setupGroupTransport();
|
||||||
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
|
await scheduleGrouped(transport, gen, "b", "vo");
|
||||||
|
const aSolo = mock.gainNodes[1]!;
|
||||||
|
const bSolo = mock.gainNodes[6]!;
|
||||||
|
|
||||||
|
transport.setSolo(new Set(["vo"]));
|
||||||
|
|
||||||
|
expect(aSolo.gain.value).toBe(1);
|
||||||
|
expect(bSolo.gain.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearing solo (empty set) restores every member to audible", async () => {
|
||||||
|
const { transport, mock, gen } = setupGroupTransport();
|
||||||
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
|
const aSolo = mock.gainNodes[1]!;
|
||||||
|
|
||||||
|
transport.setSolo(new Set(["other"]));
|
||||||
|
expect(aSolo.gain.value).toBe(0);
|
||||||
|
|
||||||
|
transport.setSolo(new Set());
|
||||||
|
expect(aSolo.gain.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a newly scheduled member picks up an already-active solo immediately", async () => {
|
||||||
|
const { transport, mock, gen } = setupGroupTransport();
|
||||||
|
transport.setSolo(new Set(["a"]));
|
||||||
|
|
||||||
|
await scheduleGrouped(transport, gen, "a");
|
||||||
|
await scheduleGrouped(transport, gen, "b");
|
||||||
|
|
||||||
|
expect(mock.gainNodes[1]!.gain.value).toBe(1); // a's own solo gain
|
||||||
|
expect(mock.gainNodes[3]!.gain.value).toBe(0); // b's own solo gain
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("group mute (B5)", () => {
|
||||||
|
it("a group created with data-hidden already set starts muted (mute gain at 0)", async () => {
|
||||||
|
document.body.innerHTML = `<hf-audio-group id="vo" data-hidden></hf-audio-group>`;
|
||||||
|
const { transport, mock, gen } = setupGroupTransport();
|
||||||
|
|
||||||
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
|
|
||||||
|
const muteGain = mock.gainNodes[4]!;
|
||||||
|
expect(muteGain.gain.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setGroupMuted toggles the mute gain on an active group bus", async () => {
|
||||||
|
const { transport, mock, gen } = setupGroupTransport();
|
||||||
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
|
const muteGain = mock.gainNodes[4]!;
|
||||||
|
expect(muteGain.gain.value).toBe(1);
|
||||||
|
|
||||||
|
transport.setGroupMuted("vo", true);
|
||||||
|
expect(muteGain.gain.value).toBe(0);
|
||||||
|
|
||||||
|
transport.setGroupMuted("vo", false);
|
||||||
|
expect(muteGain.gain.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setGroupMuted on a group with no active member is a no-op, not a throw", () => {
|
||||||
|
const { transport } = setupGroupTransport();
|
||||||
|
expect(() => transport.setGroupMuted("never-played", true)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("groupLevel meter (B7)", () => {
|
describe("groupLevel meter (B7)", () => {
|
||||||
it("groupLevel returns null for an unknown/idle group id", () => {
|
it("groupLevel returns null for an unknown/idle group id", () => {
|
||||||
const { transport } = setupGroupTransport();
|
const { transport } = setupGroupTransport();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type AutomationTiming,
|
type AutomationTiming,
|
||||||
} from "../audio/audioFxAutomation.js";
|
} from "../audio/audioFxAutomation.js";
|
||||||
import { VOLUME_RANGE } from "../audioAutomation.js";
|
import { VOLUME_RANGE } from "../audioAutomation.js";
|
||||||
import { audioGroupOf } from "../audioGroups.js";
|
import { audioGroupOf, isAudibleUnderSolo } from "../audioGroups.js";
|
||||||
import { swallow } from "./diagnostics";
|
import { swallow } from "./diagnostics";
|
||||||
import { clampAudioGain } from "../audioGain.js";
|
import { clampAudioGain } from "../audioGain.js";
|
||||||
import { getDebugSurface } from "./globals.js";
|
import { getDebugSurface } from "./globals.js";
|
||||||
@@ -88,6 +88,11 @@ function scheduleVolumeLane(
|
|||||||
type ScheduledSourceBase = {
|
type ScheduledSourceBase = {
|
||||||
el: HTMLMediaElement;
|
el: HTMLMediaElement;
|
||||||
gainNode: GainNode;
|
gainNode: GainNode;
|
||||||
|
/** Solo ("Hear only this") attenuation — dedicated node, parallel to the
|
||||||
|
* volume gain, so a solo toggle never fights `scheduleVolumeLane`'s ramps
|
||||||
|
* on the same param (same hazard B5's group-mute gain was split out to
|
||||||
|
* avoid). 0 while silenced by an active solo elsewhere, 1 otherwise. */
|
||||||
|
soloGain: GainNode;
|
||||||
/** FX chain spliced between source and gain, when the element carries one. */
|
/** FX chain spliced between source and gain, when the element carries one. */
|
||||||
fx?: ElementFxHandle | null;
|
fx?: ElementFxHandle | null;
|
||||||
compositionStart: number;
|
compositionStart: number;
|
||||||
@@ -129,7 +134,13 @@ export class WebAudioTransport {
|
|||||||
// a group does not rebuild its chain; only `destroy()` disposes these.
|
// a group does not rebuild its chain; only `destroy()` disposes these.
|
||||||
private _groups = new Map<
|
private _groups = new Map<
|
||||||
string,
|
string,
|
||||||
{ input: GainNode; analyser: AnalyserNode; levelBuf: Float32Array; dispose(): void }
|
{
|
||||||
|
input: GainNode;
|
||||||
|
muteGain: GainNode;
|
||||||
|
analyser: AnalyserNode;
|
||||||
|
levelBuf: Float32Array;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
>();
|
>();
|
||||||
// Composition-time reference frame: at AudioContext time `_rateAnchorCtx`,
|
// Composition-time reference frame: at AudioContext time `_rateAnchorCtx`,
|
||||||
// composition time was `_rateAnchorComp`, and time has been advancing at
|
// composition time was `_rateAnchorComp`, and time has been advancing at
|
||||||
@@ -139,6 +150,10 @@ export class WebAudioTransport {
|
|||||||
private _rate = 1;
|
private _rate = 1;
|
||||||
private _paused = true;
|
private _paused = true;
|
||||||
private _playGeneration = 0;
|
private _playGeneration = 0;
|
||||||
|
// Session-only "Hear only this" set (clip ids and group ids). Never read
|
||||||
|
// from or written to any attribute — studio pushes it in directly via
|
||||||
|
// `setSolo`; see `isAudibleUnderSolo` for the exact predicate.
|
||||||
|
private _soloed: ReadonlySet<string> = new Set();
|
||||||
|
|
||||||
async init(): Promise<boolean> {
|
async init(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -300,10 +315,10 @@ export class WebAudioTransport {
|
|||||||
// Stable point the FX chain (or, when there's none, the dry passthrough —
|
// Stable point the FX chain (or, when there's none, the dry passthrough —
|
||||||
// see `attachElementFxChain`'s `detach()`) always lands on before master,
|
// see `attachElementFxChain`'s `detach()`) always lands on before master,
|
||||||
// regardless of whether a chain is attached/detached/rebuilt later. B7's
|
// regardless of whether a chain is attached/detached/rebuilt later. B7's
|
||||||
// meter taps here. B5's group mute gain MUST splice in before `output`
|
// meter taps here. The mute gain splices in BEFORE `output` (between the
|
||||||
// (between the FX chain and here), never after — the meter is defined to
|
// FX chain and here), never after — the meter is defined to read the
|
||||||
// read the group's true, honestly-muted level (design doc §5), and this
|
// group's true, honestly-muted level (design doc §5), and this node is
|
||||||
// node is that contract's anchor.
|
// that contract's anchor.
|
||||||
const output = this._ctx.createGain();
|
const output = this._ctx.createGain();
|
||||||
output.connect(this._masterGain);
|
output.connect(this._masterGain);
|
||||||
const analyser = this._ctx.createAnalyser();
|
const analyser = this._ctx.createAnalyser();
|
||||||
@@ -311,23 +326,28 @@ export class WebAudioTransport {
|
|||||||
output.connect(analyser);
|
output.connect(analyser);
|
||||||
|
|
||||||
const groupEl = doc.getElementById(groupId);
|
const groupEl = doc.getElementById(groupId);
|
||||||
|
const muteGain = this._ctx.createGain();
|
||||||
|
muteGain.gain.value = groupEl?.hasAttribute("data-hidden") ? 0 : 1;
|
||||||
|
muteGain.connect(output);
|
||||||
const fx = attachElementFxChain(
|
const fx = attachElementFxChain(
|
||||||
this._ctx,
|
this._ctx,
|
||||||
groupEl ?? { getAttribute: () => null },
|
groupEl ?? { getAttribute: () => null },
|
||||||
input,
|
input,
|
||||||
output,
|
muteGain,
|
||||||
timing,
|
timing,
|
||||||
);
|
);
|
||||||
if (groupEl) scheduleVolumeLane(groupEl, input, timing);
|
if (groupEl) scheduleVolumeLane(groupEl, input, timing);
|
||||||
|
|
||||||
this._groups.set(groupId, {
|
this._groups.set(groupId, {
|
||||||
input,
|
input,
|
||||||
|
muteGain,
|
||||||
analyser,
|
analyser,
|
||||||
levelBuf: new Float32Array(analyser.fftSize),
|
levelBuf: new Float32Array(analyser.fftSize),
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
try {
|
try {
|
||||||
fx?.dispose();
|
fx?.dispose();
|
||||||
input.disconnect();
|
input.disconnect();
|
||||||
|
muteGain.disconnect();
|
||||||
output.disconnect();
|
output.disconnect();
|
||||||
analyser.disconnect();
|
analyser.disconnect();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -338,6 +358,24 @@ export class WebAudioTransport {
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group mute, preview side — a separate gain from `input`'s volume fader
|
||||||
|
* (B7) so a mute toggle never fights `scheduleVolumeLane`'s ramps on the
|
||||||
|
* same param (the same hazard the design doc flags for §2.1). A no-op
|
||||||
|
* until the group has an active member: at that point `groupInput` reads
|
||||||
|
* the element's own `data-hidden` for its initial value, so there is
|
||||||
|
* nothing to catch up on here.
|
||||||
|
*/
|
||||||
|
setGroupMuted(groupId: string, muted: boolean): void {
|
||||||
|
const group = this._groups.get(groupId);
|
||||||
|
if (!group) return;
|
||||||
|
try {
|
||||||
|
group.muteGain.gain.value = muted ? 0 : 1;
|
||||||
|
} catch (err) {
|
||||||
|
swallow("webAudioTransport.setGroupMuted", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Every group id currently routing audio (built lazily by `groupInput` —
|
/** Every group id currently routing audio (built lazily by `groupInput` —
|
||||||
* a group with no active member yet has no entry here). */
|
* a group with no active member yet has no entry here). */
|
||||||
groupIds(): string[] {
|
groupIds(): string[] {
|
||||||
@@ -401,6 +439,7 @@ export class WebAudioTransport {
|
|||||||
sourceNode.disconnect();
|
sourceNode.disconnect();
|
||||||
scheduled.fx?.dispose();
|
scheduled.fx?.dispose();
|
||||||
scheduled.gainNode.disconnect();
|
scheduled.gainNode.disconnect();
|
||||||
|
scheduled.soloGain.disconnect();
|
||||||
} catch {
|
} catch {
|
||||||
// Already torn down.
|
// Already torn down.
|
||||||
}
|
}
|
||||||
@@ -453,7 +492,10 @@ export class WebAudioTransport {
|
|||||||
// output — the same order the offline render uses. Preview and render run
|
// output — the same order the offline render uses. Preview and render run
|
||||||
// the identical graph builders, so what is heard here is what is written.
|
// the identical graph builders, so what is heard here is what is written.
|
||||||
const fx = attachElementFxChain(this._ctx, el, sourceNode, gainNode, timing);
|
const fx = attachElementFxChain(this._ctx, el, sourceNode, gainNode, timing);
|
||||||
gainNode.connect(
|
const soloGain = this._ctx.createGain();
|
||||||
|
soloGain.gain.value = isAudibleUnderSolo(this._soloed, el.id, audioGroupOf(el)) ? 1 : 0;
|
||||||
|
gainNode.connect(soloGain);
|
||||||
|
soloGain.connect(
|
||||||
this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain,
|
this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -477,6 +519,7 @@ export class WebAudioTransport {
|
|||||||
sourceNode.disconnect();
|
sourceNode.disconnect();
|
||||||
fx?.dispose();
|
fx?.dispose();
|
||||||
gainNode.disconnect();
|
gainNode.disconnect();
|
||||||
|
soloGain.disconnect();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,6 +533,7 @@ export class WebAudioTransport {
|
|||||||
sourceNode,
|
sourceNode,
|
||||||
sourceKind: "buffer",
|
sourceKind: "buffer",
|
||||||
gainNode,
|
gainNode,
|
||||||
|
soloGain,
|
||||||
compositionStart,
|
compositionStart,
|
||||||
mediaStart,
|
mediaStart,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
@@ -561,6 +605,7 @@ export class WebAudioTransport {
|
|||||||
source.sourceNode.disconnect();
|
source.sourceNode.disconnect();
|
||||||
source.fx?.dispose();
|
source.fx?.dispose();
|
||||||
source.gainNode.disconnect();
|
source.gainNode.disconnect();
|
||||||
|
source.soloGain.disconnect();
|
||||||
} catch {
|
} catch {
|
||||||
// already stopped
|
// already stopped
|
||||||
}
|
}
|
||||||
@@ -609,6 +654,31 @@ export class WebAudioTransport {
|
|||||||
if (this._masterGain) this._masterGain.gain.value = this._masterMuted ? 0 : this._masterVolume;
|
if (this._masterGain) this._masterGain.gain.value = this._masterMuted ? 0 : this._masterVolume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the current "Hear only this" set and re-evaluate every active
|
||||||
|
* source's solo gain against it — a gain-stage update, never a graph
|
||||||
|
* rebuild (rule 3 of B5's step doc). Group buses are never touched here:
|
||||||
|
* per `isAudibleUnderSolo`, a group is never attenuated by solo, so a
|
||||||
|
* soloed member's path through its (unattenuated) group stays open by
|
||||||
|
* construction.
|
||||||
|
*/
|
||||||
|
setSolo(soloed: ReadonlySet<string>): void {
|
||||||
|
this._soloed = soloed;
|
||||||
|
for (const source of this._activeSources) {
|
||||||
|
try {
|
||||||
|
source.soloGain.gain.value = isAudibleUnderSolo(
|
||||||
|
this._soloed,
|
||||||
|
source.el.id,
|
||||||
|
audioGroupOf(source.el),
|
||||||
|
)
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
} catch (err) {
|
||||||
|
swallow("webAudioTransport.setSolo", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isActive(): boolean {
|
isActive(): boolean {
|
||||||
return this._activeSources.length > 0 && !this._paused;
|
return this._activeSources.length > 0 && !this._paused;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -37,6 +37,12 @@ declare global {
|
|||||||
onSwallowed?: (label: string, err: unknown) => void;
|
onSwallowed?: (label: string, err: unknown) => void;
|
||||||
seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
|
seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
/**
|
||||||
|
* Studio's "Hear only this" push: the full set of soloed clip/group ids,
|
||||||
|
* replaced wholesale on every change. Session-only by design — never
|
||||||
|
* read from or written to any document attribute.
|
||||||
|
*/
|
||||||
|
setAudioSolo?: (ids: readonly string[]) => void;
|
||||||
};
|
};
|
||||||
__playerReady?: boolean;
|
__playerReady?: boolean;
|
||||||
__renderReady?: boolean;
|
__renderReady?: boolean;
|
||||||
|
|||||||
@@ -1396,6 +1396,18 @@ describe("parseAudioElements — hidden tracks", () => {
|
|||||||
"visible-video-audio",
|
"visible-video-audio",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("excludes every member of a hidden group, even though the members carry no data-hidden of their own", () => {
|
||||||
|
const html =
|
||||||
|
`<div data-composition-id="main" data-start="0" data-duration="3">` +
|
||||||
|
`<hf-audio-group id="vo" data-hidden></hf-audio-group>` +
|
||||||
|
`<audio id="master" src="master.wav" data-start="0" data-duration="3"></audio>` +
|
||||||
|
`<audio id="a" src="a.wav" data-start="0" data-duration="3" data-audio-group="vo"></audio>` +
|
||||||
|
`<audio id="b" src="b.wav" data-start="0" data-duration="3" data-audio-group="vo"></audio>` +
|
||||||
|
`</div>`;
|
||||||
|
|
||||||
|
expect(parseAudioElements(html).map((track) => track.id)).toEqual(["master"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseAudioElements data-fx-chain", () => {
|
describe("parseAudioElements data-fx-chain", () => {
|
||||||
|
|||||||
+13
-16
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react";
|
import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react";
|
||||||
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
|
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
|
||||||
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
||||||
import { usePlayerStore } from "./player";
|
import { usePlayerStore } from "./player";
|
||||||
@@ -38,6 +38,7 @@ import { useToast } from "./hooks/useToast";
|
|||||||
import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
|
import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
|
||||||
import { useStudioUrlState } from "./hooks/useStudioUrlState";
|
import { useStudioUrlState } from "./hooks/useStudioUrlState";
|
||||||
import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration";
|
import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration";
|
||||||
|
import { useAudioSoloBridge } from "./hooks/useAudioSoloBridge";
|
||||||
import {
|
import {
|
||||||
buildStudioContextValue,
|
buildStudioContextValue,
|
||||||
useGlobalFileDrop,
|
useGlobalFileDrop,
|
||||||
@@ -61,11 +62,8 @@ import { StudioSplash } from "./components/StudioSplash";
|
|||||||
import { useServerConnection } from "./hooks/useServerConnection";
|
import { useServerConnection } from "./hooks/useServerConnection";
|
||||||
import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
|
import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
|
||||||
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
|
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
|
||||||
import {
|
import { readStudioUrlStateFromWindow, resolveMasterCompositionPath } from "./utils/studioUrlState";
|
||||||
normalizeStudioCompositionPath,
|
import { useHydrateActiveCompPathFromUrl } from "./hooks/useHydrateActiveCompPathFromUrl";
|
||||||
readStudioUrlStateFromWindow,
|
|
||||||
resolveMasterCompositionPath,
|
|
||||||
} from "./utils/studioUrlState";
|
|
||||||
const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
|
const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
export function StudioApp() {
|
export function StudioApp() {
|
||||||
@@ -84,6 +82,7 @@ export function StudioApp() {
|
|||||||
const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
|
const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
|
||||||
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
|
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
|
||||||
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
useAudioSoloBridge(previewIframeRef);
|
||||||
const activeCompPathRef = useRef(activeCompPath);
|
const activeCompPathRef = useRef(activeCompPath);
|
||||||
activeCompPathRef.current = activeCompPath;
|
activeCompPathRef.current = activeCompPath;
|
||||||
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
|
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
|
||||||
@@ -127,16 +126,14 @@ export function StudioApp() {
|
|||||||
activeCompPath,
|
activeCompPath,
|
||||||
masterCompPath,
|
masterCompPath,
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useHydrateActiveCompPathFromUrl({
|
||||||
if (activeCompPathHydrated) return;
|
hydrated: activeCompPathHydrated,
|
||||||
if (!fileManager.fileTreeLoaded) return;
|
fileTreeLoaded: fileManager.fileTreeLoaded,
|
||||||
const nextCompPath = normalizeStudioCompositionPath(
|
fileTree: fileManager.fileTree,
|
||||||
initialUrlStateRef.current.activeCompPath,
|
initialUrlStateRef,
|
||||||
fileManager.fileTree,
|
setActiveCompPath,
|
||||||
);
|
setHydrated: setActiveCompPathHydrated,
|
||||||
setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath));
|
});
|
||||||
setActiveCompPathHydrated(true);
|
|
||||||
}, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
|
|
||||||
const previewPersistence = usePreviewPersistence({
|
const previewPersistence = usePreviewPersistence({
|
||||||
showToast,
|
showToast,
|
||||||
readOptionalProjectFile: fileManager.readOptionalProjectFile,
|
readOptionalProjectFile: fileManager.readOptionalProjectFile,
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export function PreviewPane({
|
|||||||
disabled={timelineDisabled}
|
disabled={timelineDisabled}
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
onToggleFullscreen={toggleFullscreen}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
|
previewIframeRef={iframeRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
||||||
|
import { usePlayerStore } from "../player/store/playerStore";
|
||||||
|
import { getTimelineElementDisplayLabel } from "../player/lib/timelineElementHelpers";
|
||||||
|
|
||||||
|
interface IframeWindow extends Window {
|
||||||
|
__hf?: { setAudioSolo?: (ids: readonly string[]) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes the studio's "Hear only this" set into the preview runtime whenever
|
||||||
|
* it changes. A dedicated push, not a DOM write: solo is session-only and
|
||||||
|
* must never touch an attribute (design doc §2.2 / the export-safety
|
||||||
|
* guarantee), so it can't ride `syncTimedElementVisibility`'s attribute-diff
|
||||||
|
* the way group mute does — see `window.__hf.setAudioSolo`.
|
||||||
|
*/
|
||||||
|
export function useAudioSoloBridge(previewIframeRef: { current: HTMLIFrameElement | null }): void {
|
||||||
|
const soloed = usePlayerStore((s) => s.soloed);
|
||||||
|
useEffect(() => {
|
||||||
|
const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
|
||||||
|
win?.__hf?.setAudioSolo?.([...soloed]);
|
||||||
|
}, [soloed, previewIframeRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One soloed id's display label — reads the live preview DOM directly (same
|
||||||
|
* approach as `patchLiveGroupAttribute`), since solo ids are never anywhere
|
||||||
|
* but the document's own element ids. A group carries its label on
|
||||||
|
* `data-label`; anything else falls back to the same label rule the
|
||||||
|
* timeline itself uses. */
|
||||||
|
function resolveSoloLabel(doc: Document | null | undefined, id: string): string {
|
||||||
|
const el = doc?.getElementById(id);
|
||||||
|
if (!el) return id;
|
||||||
|
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
|
||||||
|
return getTimelineElementDisplayLabel({ id, label: el.getAttribute("data-label") });
|
||||||
|
}
|
||||||
|
return getTimelineElementDisplayLabel({
|
||||||
|
id,
|
||||||
|
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
||||||
|
tag: el.tagName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transport bar's "Hear only this" banner text — `null` while nothing is
|
||||||
|
* soloed. One name when exactly one thing is soloed, `"N tracks"` otherwise
|
||||||
|
* (design doc §2.2's banner rule); "your export is not affected" is fixed
|
||||||
|
* copy the caller owns, this only resolves the variable half.
|
||||||
|
*/
|
||||||
|
export function useSoloBannerText(previewIframeRef: {
|
||||||
|
current: HTMLIFrameElement | null;
|
||||||
|
}): string | null {
|
||||||
|
const soloed = usePlayerStore((s) => s.soloed);
|
||||||
|
return useMemo(() => {
|
||||||
|
if (soloed.size === 0) return null;
|
||||||
|
if (soloed.size === 1) {
|
||||||
|
const doc = previewIframeRef.current?.contentDocument;
|
||||||
|
return resolveSoloLabel(doc, [...soloed][0]);
|
||||||
|
}
|
||||||
|
return `${soloed.size} tracks`;
|
||||||
|
}, [soloed, previewIframeRef]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { MutableRefObject } from "react";
|
||||||
|
import { normalizeStudioCompositionPath, type StudioUrlState } from "../utils/studioUrlState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time hydration of `activeCompPath` from the initial URL state, once the
|
||||||
|
* file tree has loaded (a path that isn't in the tree yet can't be
|
||||||
|
* validated). Runs exactly once — `hydrated` flips true whether or not the
|
||||||
|
* URL named a valid path, so a later file-tree change never re-fires it.
|
||||||
|
*/
|
||||||
|
export function useHydrateActiveCompPathFromUrl({
|
||||||
|
hydrated,
|
||||||
|
fileTreeLoaded,
|
||||||
|
fileTree,
|
||||||
|
initialUrlStateRef,
|
||||||
|
setActiveCompPath,
|
||||||
|
setHydrated,
|
||||||
|
}: {
|
||||||
|
hydrated: boolean;
|
||||||
|
fileTreeLoaded: boolean;
|
||||||
|
fileTree: string[];
|
||||||
|
initialUrlStateRef: MutableRefObject<StudioUrlState>;
|
||||||
|
setActiveCompPath: (updater: (current: string | null) => string | null) => void;
|
||||||
|
setHydrated: (value: boolean) => void;
|
||||||
|
}): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (hydrated) return;
|
||||||
|
if (!fileTreeLoaded) return;
|
||||||
|
const nextCompPath = normalizeStudioCompositionPath(
|
||||||
|
initialUrlStateRef.current.activeCompPath,
|
||||||
|
fileTree,
|
||||||
|
);
|
||||||
|
setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath));
|
||||||
|
setHydrated(true);
|
||||||
|
}, [hydrated, fileTree, fileTreeLoaded, initialUrlStateRef, setActiveCompPath, setHydrated]);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
|
|||||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||||
import { Tooltip } from "../../components/ui";
|
import { Tooltip } from "../../components/ui";
|
||||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||||
|
import { useSoloBannerText } from "../../hooks/useAudioSoloBridge";
|
||||||
import { ShortcutsPanel } from "./ShortcutsPanel";
|
import { ShortcutsPanel } from "./ShortcutsPanel";
|
||||||
import { SpeedMenu } from "./SpeedMenu";
|
import { SpeedMenu } from "./SpeedMenu";
|
||||||
import { VolumeControl } from "./VolumeControl";
|
import { VolumeControl } from "./VolumeControl";
|
||||||
@@ -153,6 +154,34 @@ const FullscreenButton = memo(function FullscreenButton({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SoloBanner = memo(function SoloBanner({
|
||||||
|
previewIframeRef,
|
||||||
|
}: {
|
||||||
|
previewIframeRef: { current: HTMLIFrameElement | null };
|
||||||
|
}) {
|
||||||
|
const bannerText = useSoloBannerText(previewIframeRef);
|
||||||
|
const clearSolo = usePlayerStore.getState().clearSolo;
|
||||||
|
if (bannerText === null) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex h-7 items-center justify-center gap-2 border-b border-neutral-800 bg-neutral-900/90 px-3 text-[11px] text-neutral-300"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Hearing only <span className="font-medium text-neutral-100">{bannerText}</span> — your
|
||||||
|
export is not affected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => clearSolo()}
|
||||||
|
className="rounded px-1.5 py-0.5 font-medium text-studio-accent transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/* ── Main component ──────────────────────────────────────────────── */
|
/* ── Main component ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
interface PlayerControlsProps {
|
interface PlayerControlsProps {
|
||||||
@@ -161,6 +190,7 @@ interface PlayerControlsProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isFullscreen?: boolean;
|
isFullscreen?: boolean;
|
||||||
onToggleFullscreen?: () => void;
|
onToggleFullscreen?: () => void;
|
||||||
|
previewIframeRef?: { current: HTMLIFrameElement | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerControls = memo(function PlayerControls({
|
export const PlayerControls = memo(function PlayerControls({
|
||||||
@@ -169,6 +199,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
isFullscreen = false,
|
isFullscreen = false,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
|
previewIframeRef,
|
||||||
}: PlayerControlsProps) {
|
}: PlayerControlsProps) {
|
||||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||||
const duration = usePlayerStore((s) => s.duration);
|
const duration = usePlayerStore((s) => s.duration);
|
||||||
@@ -220,73 +251,80 @@ export const PlayerControls = memo(function PlayerControls({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
|
{previewIframeRef && <SoloBanner previewIframeRef={previewIframeRef} />}
|
||||||
aria-disabled={disabled || undefined}
|
<div
|
||||||
style={{
|
className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
|
||||||
paddingBottom: "env(safe-area-inset-bottom)",
|
aria-disabled={disabled || undefined}
|
||||||
}}
|
style={{
|
||||||
>
|
paddingBottom: "env(safe-area-inset-bottom)",
|
||||||
<Tooltip
|
}}
|
||||||
label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
|
|
||||||
>
|
>
|
||||||
<button
|
<Tooltip
|
||||||
type="button"
|
label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
|
||||||
onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
|
|
||||||
disabled={disabled}
|
|
||||||
className="min-w-0 justify-self-start whitespace-nowrap font-mono text-[11px] tabular-nums text-neutral-400 transition-colors hover:text-neutral-200 disabled:pointer-events-none"
|
|
||||||
>
|
>
|
||||||
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
<button
|
||||||
{timeDisplayMode === "time" ? (
|
type="button"
|
||||||
<>
|
onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
|
||||||
<span className="mx-0.5 text-neutral-700">/</span>
|
disabled={disabled}
|
||||||
<span className="text-neutral-600">{formatTime(duration)}</span>
|
className="min-w-0 justify-self-start whitespace-nowrap font-mono text-[11px] tabular-nums text-neutral-400 transition-colors hover:text-neutral-200 disabled:pointer-events-none"
|
||||||
</>
|
>
|
||||||
) : null}
|
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
||||||
</button>
|
{timeDisplayMode === "time" ? (
|
||||||
</Tooltip>
|
<>
|
||||||
|
<span className="mx-0.5 text-neutral-700">/</span>
|
||||||
|
<span className="text-neutral-600">{formatTime(duration)}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip label={isPlaying ? "Pause" : "Play"}>
|
<Tooltip label={isPlaying ? "Pause" : "Play"}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={isPlaying ? "Pause" : "Play"}
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
|
trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
|
||||||
onTogglePlay();
|
onTogglePlay();
|
||||||
}}
|
}}
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
className="flex h-8 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
|
className="flex h-8 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<PlayPauseMorphIcon playing={isPlaying} />
|
<PlayPauseMorphIcon playing={isPlaying} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className="flex min-w-0 items-center justify-self-end">
|
<div className="flex min-w-0 items-center justify-self-end">
|
||||||
<VolumeControl
|
<VolumeControl
|
||||||
audioMuted={audioMuted}
|
audioMuted={audioMuted}
|
||||||
audioVolume={audioVolume}
|
audioVolume={audioVolume}
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
setAudioMuted={setAudioMuted}
|
setAudioMuted={setAudioMuted}
|
||||||
setAudioVolume={setAudioVolume}
|
setAudioVolume={setAudioVolume}
|
||||||
/>
|
/>
|
||||||
<SpeedMenu
|
<SpeedMenu
|
||||||
playbackRate={playbackRate}
|
playbackRate={playbackRate}
|
||||||
setPlaybackRate={setPlaybackRate}
|
setPlaybackRate={setPlaybackRate}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<LoopButton loopEnabled={loopEnabled} disabled={disabled} setLoopEnabled={setLoopEnabled} />
|
<LoopButton
|
||||||
{onToggleFullscreen && (
|
loopEnabled={loopEnabled}
|
||||||
<FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
|
disabled={disabled}
|
||||||
)}
|
setLoopEnabled={setLoopEnabled}
|
||||||
<ShortcutsPanel
|
/>
|
||||||
disabled={disabled}
|
{onToggleFullscreen && (
|
||||||
duration={duration}
|
<FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
|
||||||
inPoint={inPoint}
|
)}
|
||||||
outPoint={outPoint}
|
<ShortcutsPanel
|
||||||
setInPoint={setInPoint}
|
disabled={disabled}
|
||||||
setOutPoint={setOutPoint}
|
duration={duration}
|
||||||
onSeek={onSeek}
|
inPoint={inPoint}
|
||||||
/>
|
outPoint={outPoint}
|
||||||
|
setInPoint={setInPoint}
|
||||||
|
setOutPoint={setOutPoint}
|
||||||
|
onSeek={onSeek}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
||||||
import { TRACK_H } from "./timelineLayout";
|
import { TRACK_H } from "./timelineLayout";
|
||||||
import type { TimelineTheme } from "./timelineTheme";
|
import type { TimelineTheme } from "./timelineTheme";
|
||||||
|
|
||||||
@@ -11,14 +12,23 @@ interface TimelineGroupHeaderProps {
|
|||||||
laneCount: number;
|
laneCount: number;
|
||||||
isLaneOpen: boolean;
|
isLaneOpen: boolean;
|
||||||
onToggleLanes: () => void;
|
onToggleLanes: () => void;
|
||||||
|
/** The group element's own `data-hidden` — mutes every member at once. */
|
||||||
|
hidden: boolean;
|
||||||
|
onToggleHidden: () => void;
|
||||||
|
/** This group id is itself in the soloed set (fully lit). */
|
||||||
|
isSoloed: boolean;
|
||||||
|
/** Not soloed itself, but at least one member is (half-lit). */
|
||||||
|
isHalfLitSolo: boolean;
|
||||||
|
/** `add: true` (⌘/Ctrl-click) toggles membership; a plain click is exclusive. */
|
||||||
|
onToggleSolo: (options?: { add?: boolean }) => void;
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
theme: TimelineTheme;
|
theme: TimelineTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A group's own row header: caret (member disclosure) + `▤` + label + `∿ n`
|
* A group's own row header: caret (member disclosure) + `▤` + label + count +
|
||||||
* (lane disclosure). Mute/solo (B5) and the FX entry point (C1) land here as
|
* mute + solo + `∿ n` (lane disclosure). The FX entry point (C1) lands here
|
||||||
* siblings once those steps exist — nothing to reserve for them yet.
|
* as a sibling once that step exists.
|
||||||
*/
|
*/
|
||||||
export function TimelineGroupHeader({
|
export function TimelineGroupHeader({
|
||||||
label,
|
label,
|
||||||
@@ -28,6 +38,11 @@ export function TimelineGroupHeader({
|
|||||||
laneCount,
|
laneCount,
|
||||||
isLaneOpen,
|
isLaneOpen,
|
||||||
onToggleLanes,
|
onToggleLanes,
|
||||||
|
hidden,
|
||||||
|
onToggleHidden,
|
||||||
|
isSoloed,
|
||||||
|
isHalfLitSolo,
|
||||||
|
onToggleSolo,
|
||||||
columnWidth,
|
columnWidth,
|
||||||
theme,
|
theme,
|
||||||
}: TimelineGroupHeaderProps) {
|
}: TimelineGroupHeaderProps) {
|
||||||
@@ -76,6 +91,47 @@ export function TimelineGroupHeader({
|
|||||||
>
|
>
|
||||||
{memberCount}
|
{memberCount}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={hidden ? "Unmute group" : `Mute group ${label}`}
|
||||||
|
title={hidden ? "Unmute group" : `Mute group ${label}`}
|
||||||
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||||
|
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/55 hover:text-white"
|
||||||
|
}`}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleHidden();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hidden ? (
|
||||||
|
<SpeakerSlash size={14} weight="bold" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<SpeakerHigh size={14} weight="bold" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-pressed={isSoloed}
|
||||||
|
aria-label="Hear only this"
|
||||||
|
title="Hear only this"
|
||||||
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||||
|
isSoloed
|
||||||
|
? "text-[#F5C542] hover:text-white"
|
||||||
|
: isHalfLitSolo
|
||||||
|
? "text-[#F5C542]/50 hover:text-white"
|
||||||
|
: "text-white/35 hover:text-white/75"
|
||||||
|
}`}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleSolo({ add: event.metaKey || event.ctrlKey });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">⌗</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
|
import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
|
||||||
import type { TimelineTheme } from "./timelineTheme";
|
import type { TimelineTheme } from "./timelineTheme";
|
||||||
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||||
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
||||||
@@ -54,6 +56,9 @@ export function TimelineGroupRow({
|
|||||||
});
|
});
|
||||||
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
|
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
|
||||||
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
|
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
|
||||||
|
const soloed = usePlayerStore((s) => s.soloed);
|
||||||
|
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
|
||||||
|
const memberIds = memberElements.map((el) => el.key ?? el.id);
|
||||||
return (
|
return (
|
||||||
<TimelineTrackRow
|
<TimelineTrackRow
|
||||||
index={index}
|
index={index}
|
||||||
@@ -77,6 +82,18 @@ export function TimelineGroupRow({
|
|||||||
laneCount={groupAutomationLanes(memberElements).length}
|
laneCount={groupAutomationLanes(memberElements).length}
|
||||||
isLaneOpen={isLaneOpen}
|
isLaneOpen={isLaneOpen}
|
||||||
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
||||||
|
hidden={group.hidden}
|
||||||
|
onToggleHidden={() =>
|
||||||
|
onSetAudioGroupAttributeQuiet?.(
|
||||||
|
group.id,
|
||||||
|
"data-hidden",
|
||||||
|
group.hidden ? null : "",
|
||||||
|
group.hidden ? "Unmute group" : `Mute group ${group.label}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isSoloed={soloed.has(group.id)}
|
||||||
|
isHalfLitSolo={isGroupHalfLitUnderSolo(soloed, group.id, memberIds)}
|
||||||
|
onToggleSolo={(options) => toggleSolo(group.id, options)}
|
||||||
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* "Hear only this" — the `⌗` toggle beside a track's mute control. Session
|
||||||
|
* state only (see `audioSoloSlice`): a plain click is exclusive, ⌘/Ctrl-click
|
||||||
|
* toggles membership without disturbing the rest of the set.
|
||||||
|
*/
|
||||||
|
export function TimelineSoloButton({
|
||||||
|
isSoloed,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
isSoloed: boolean;
|
||||||
|
onToggle: (options?: { add?: boolean }) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-pressed={isSoloed}
|
||||||
|
aria-label="Hear only this"
|
||||||
|
title="Hear only this"
|
||||||
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||||
|
isSoloed ? "text-[#F5C542] hover:text-white" : "text-white/35 hover:text-white/75"
|
||||||
|
}`}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggle({ add: event.metaKey || event.ctrlKey });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">⌗</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
|
||||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||||
import { Music } from "../../icons/SystemIcons";
|
import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
|
||||||
import type { TimelineElement } from "../store/playerStore";
|
|
||||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||||
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||||
import { groupAutomationLanes } from "./automationLaneData";
|
import { groupAutomationLanes } from "./automationLaneData";
|
||||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||||
import { clipTimingStart } from "../../hooks/gsapShared";
|
import { clipTimingStart } from "../../hooks/gsapShared";
|
||||||
import { LayerDisclosureRow } from "./LayerDisclosureRow";
|
import { LayerDisclosureRow } from "./LayerDisclosureRow";
|
||||||
import { TrackClipCount } from "./TrackClipCount";
|
|
||||||
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
||||||
import type { TimelineTheme } from "./timelineTheme";
|
import type { TimelineTheme } from "./timelineTheme";
|
||||||
import {
|
import {
|
||||||
@@ -62,109 +59,6 @@ interface TimelineTrackHeaderProps {
|
|||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows.
|
|
||||||
// Gated: the relabel ships behind the canary, unlike the preview fix.
|
|
||||||
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
|
|
||||||
if (showAsMute) return hidden ? "Muted" : "Mute";
|
|
||||||
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) {
|
|
||||||
const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
|
|
||||||
return <Icon size={14} weight="bold" aria-hidden="true" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VisibilityButton({
|
|
||||||
hidden,
|
|
||||||
trackNumber,
|
|
||||||
trackDisplayNumber,
|
|
||||||
visible,
|
|
||||||
isAudioTrack,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
hidden: boolean;
|
|
||||||
trackNumber: number;
|
|
||||||
trackDisplayNumber: number | null;
|
|
||||||
visible: boolean;
|
|
||||||
isAudioTrack?: boolean;
|
|
||||||
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
||||||
}) {
|
|
||||||
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
|
||||||
// Display number in the text, real key in the callback. The two must not be
|
|
||||||
// conflated in either direction.
|
|
||||||
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
|
||||||
const showAsMute = Boolean(isAudioTrack) && isCanaryEnabled("audio-track-mute");
|
|
||||||
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={label}
|
|
||||||
title={label}
|
|
||||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
|
||||||
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
|
|
||||||
}`}
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
void onToggle?.(trackNumber, !hidden);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visibilityButtonIcon(showAsMute, hidden)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The header a track gets when it has no keyframe clip to disclose: label, clip
|
|
||||||
// count, eye. Not deprecated — it is the live path for every track without lanes.
|
|
||||||
function PlainTrackHeader({
|
|
||||||
trackNumber,
|
|
||||||
trackDisplayNumber,
|
|
||||||
trackLabel,
|
|
||||||
clipCount,
|
|
||||||
showTrackLabel,
|
|
||||||
isTrackHidden,
|
|
||||||
isAudioTrack,
|
|
||||||
onToggleTrackHidden,
|
|
||||||
}: Pick<
|
|
||||||
TimelineTrackHeaderProps,
|
|
||||||
| "trackNumber"
|
|
||||||
| "trackDisplayNumber"
|
|
||||||
| "trackLabel"
|
|
||||||
| "clipCount"
|
|
||||||
| "isTrackHidden"
|
|
||||||
| "isAudioTrack"
|
|
||||||
| "onToggleTrackHidden"
|
|
||||||
> & { showTrackLabel: boolean }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isAudioTrack && (
|
|
||||||
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
|
||||||
)}
|
|
||||||
{showTrackLabel && (
|
|
||||||
<span
|
|
||||||
className={`min-w-0 flex-1 truncate text-[11px] ${
|
|
||||||
isAudioTrack && isTrackHidden && isCanaryEnabled("audio-track-mute")
|
|
||||||
? "line-through"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
title={trackLabel}
|
|
||||||
>
|
|
||||||
{trackLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
|
||||||
<VisibilityButton
|
|
||||||
hidden={isTrackHidden}
|
|
||||||
trackNumber={trackNumber}
|
|
||||||
trackDisplayNumber={trackDisplayNumber}
|
|
||||||
visible
|
|
||||||
isAudioTrack={isAudioTrack}
|
|
||||||
onToggle={onToggleTrackHidden}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
|
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
|
||||||
function PropertyGroupNavigation({
|
function PropertyGroupNavigation({
|
||||||
navigation,
|
navigation,
|
||||||
@@ -478,6 +372,13 @@ export function TimelineTrackHeader({
|
|||||||
// left an audio clip's envelopes unreachable, since the track could not expand.
|
// left an audio clip's envelopes unreachable, since the track could not expand.
|
||||||
const disclosable = lanes.length > 0 || automationRows.length > 0;
|
const disclosable = lanes.length > 0 || automationRows.length > 0;
|
||||||
const isKeyframeLayer = !!keyframeClip && disclosable;
|
const isKeyframeLayer = !!keyframeClip && disclosable;
|
||||||
|
// Solo is per-clip/per-group, never per track (design doc §2.2) — this header
|
||||||
|
// acts on the track's first clip as a pragmatic stand-in for "this track",
|
||||||
|
// the same simplification the mute button doesn't need to make (it patches
|
||||||
|
// every clip on the track at once).
|
||||||
|
const soloTargetId = trackElements[0] ? (trackElements[0].key ?? trackElements[0].id) : null;
|
||||||
|
const soloed = usePlayerStore((s) => s.soloed);
|
||||||
|
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -505,6 +406,9 @@ export function TimelineTrackHeader({
|
|||||||
showTrackLabel={showTrackLabel}
|
showTrackLabel={showTrackLabel}
|
||||||
isTrackHidden={isTrackHidden}
|
isTrackHidden={isTrackHidden}
|
||||||
isAudioTrack={isAudioTrack}
|
isAudioTrack={isAudioTrack}
|
||||||
|
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
|
||||||
|
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
|
||||||
|
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
|
||||||
onToggleTrackHidden={onToggleTrackHidden}
|
onToggleTrackHidden={onToggleTrackHidden}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
||||||
|
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||||
|
import { Music } from "../../icons/SystemIcons";
|
||||||
|
import { TimelineSoloButton } from "./TimelineSoloButton";
|
||||||
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||||
|
import { TrackClipCount } from "./TrackClipCount";
|
||||||
|
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
||||||
|
|
||||||
|
// Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows.
|
||||||
|
// Gated: the relabel ships behind the canary, unlike the preview fix.
|
||||||
|
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
|
||||||
|
if (showAsMute) return hidden ? "Muted" : "Mute";
|
||||||
|
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) {
|
||||||
|
const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
|
||||||
|
return <Icon size={14} weight="bold" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VisibilityButton({
|
||||||
|
hidden,
|
||||||
|
trackNumber,
|
||||||
|
trackDisplayNumber,
|
||||||
|
visible,
|
||||||
|
isAudioTrack,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
hidden: boolean;
|
||||||
|
trackNumber: number;
|
||||||
|
trackDisplayNumber: number | null;
|
||||||
|
visible: boolean;
|
||||||
|
isAudioTrack?: boolean;
|
||||||
|
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
||||||
|
}) {
|
||||||
|
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
||||||
|
// Display number in the text, real key in the callback. The two must not be
|
||||||
|
// conflated in either direction.
|
||||||
|
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
||||||
|
const showAsMute = Boolean(isAudioTrack) && isCanaryEnabled("audio-track-mute");
|
||||||
|
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||||
|
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
|
||||||
|
}`}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void onToggle?.(trackNumber, !hidden);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibilityButtonIcon(showAsMute, hidden)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The header a track gets when it has no keyframe clip to disclose: label, clip
|
||||||
|
// count, eye. Not deprecated — it is the live path for every track without lanes.
|
||||||
|
export function PlainTrackHeader({
|
||||||
|
trackNumber,
|
||||||
|
trackDisplayNumber,
|
||||||
|
trackLabel,
|
||||||
|
clipCount,
|
||||||
|
showTrackLabel,
|
||||||
|
isTrackHidden,
|
||||||
|
isAudioTrack,
|
||||||
|
isGroupMuted,
|
||||||
|
isSoloed,
|
||||||
|
onToggleSolo,
|
||||||
|
onToggleTrackHidden,
|
||||||
|
}: {
|
||||||
|
trackNumber: number;
|
||||||
|
trackDisplayNumber: number | null;
|
||||||
|
trackLabel: string;
|
||||||
|
clipCount: number;
|
||||||
|
isTrackHidden: boolean;
|
||||||
|
isAudioTrack: boolean;
|
||||||
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
||||||
|
showTrackLabel: boolean;
|
||||||
|
isGroupMuted: boolean;
|
||||||
|
isSoloed: boolean;
|
||||||
|
onToggleSolo?: (options?: { add?: boolean }) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isAudioTrack && (
|
||||||
|
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
||||||
|
)}
|
||||||
|
{showTrackLabel && (
|
||||||
|
<span
|
||||||
|
className={`min-w-0 flex-1 truncate text-[11px] ${
|
||||||
|
isAudioTrack && (isTrackHidden || isGroupMuted) && isCanaryEnabled("audio-track-mute")
|
||||||
|
? "line-through"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
|
||||||
|
>
|
||||||
|
{trackLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
||||||
|
<VisibilityButton
|
||||||
|
hidden={isTrackHidden}
|
||||||
|
trackNumber={trackNumber}
|
||||||
|
trackDisplayNumber={trackDisplayNumber}
|
||||||
|
visible
|
||||||
|
isAudioTrack={isAudioTrack}
|
||||||
|
onToggle={onToggleTrackHidden}
|
||||||
|
/>
|
||||||
|
{isAudioTrack && isCanaryEnabled("audio-track-mute") && onToggleSolo && (
|
||||||
|
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ export interface TimelineTrackGroupInfo {
|
|||||||
memberTracks: number[];
|
memberTracks: number[];
|
||||||
/** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
|
/** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
|
||||||
volume: number;
|
volume: number;
|
||||||
|
/** The group element's `data-hidden`, mirrored from a member's parse (B5's group mute). */
|
||||||
|
hidden: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupMembership {
|
interface GroupMembership {
|
||||||
@@ -25,14 +27,16 @@ interface GroupMembership {
|
|||||||
memberTracksByGroup: Map<string, number[]>;
|
memberTracksByGroup: Map<string, number[]>;
|
||||||
labelByGroup: Map<string, string>;
|
labelByGroup: Map<string, string>;
|
||||||
volumeByGroup: Map<string, number>;
|
volumeByGroup: Map<string, number>;
|
||||||
|
hiddenByGroup: Map<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which track belongs to which group, and each group's label/volume — one pass over raw tracks. */
|
/** Which track belongs to which group, and each group's label/volume/hidden — one pass over raw tracks. */
|
||||||
function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
|
function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
|
||||||
const trackToGroupId = new Map<number, string>();
|
const trackToGroupId = new Map<number, string>();
|
||||||
const memberTracksByGroup = new Map<string, number[]>();
|
const memberTracksByGroup = new Map<string, number[]>();
|
||||||
const labelByGroup = new Map<string, string>();
|
const labelByGroup = new Map<string, string>();
|
||||||
const volumeByGroup = new Map<string, number>();
|
const volumeByGroup = new Map<string, number>();
|
||||||
|
const hiddenByGroup = new Map<string, boolean>();
|
||||||
for (const [trackNum, elements] of rawTracks) {
|
for (const [trackNum, elements] of rawTracks) {
|
||||||
const owner = elements.find((el) => el.audioGroup);
|
const owner = elements.find((el) => el.audioGroup);
|
||||||
if (!owner?.audioGroup) continue;
|
if (!owner?.audioGroup) continue;
|
||||||
@@ -40,12 +44,13 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
|
|||||||
if (!labelByGroup.has(owner.audioGroup)) {
|
if (!labelByGroup.has(owner.audioGroup)) {
|
||||||
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
|
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
|
||||||
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
|
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
|
||||||
|
hiddenByGroup.set(owner.audioGroup, owner.audioGroupHidden ?? false);
|
||||||
}
|
}
|
||||||
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
|
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
|
||||||
members.push(trackNum);
|
members.push(trackNum);
|
||||||
memberTracksByGroup.set(owner.audioGroup, members);
|
memberTracksByGroup.set(owner.audioGroup, members);
|
||||||
}
|
}
|
||||||
return { trackToGroupId, memberTracksByGroup, labelByGroup, volumeByGroup };
|
return { trackToGroupId, memberTracksByGroup, labelByGroup, volumeByGroup, hiddenByGroup };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One group's resolved row info, built once the first time its id is seen. */
|
/** One group's resolved row info, built once the first time its id is seen. */
|
||||||
@@ -63,6 +68,7 @@ function buildGroupInfo(
|
|||||||
anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
|
anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
|
||||||
memberTracks,
|
memberTracks,
|
||||||
volume: membership.volumeByGroup.get(groupId) ?? 1,
|
volume: membership.volumeByGroup.get(groupId) ?? 1,
|
||||||
|
hidden: membership.hiddenByGroup.get(groupId) ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,24 +70,27 @@ function resolveClipTag(clip: ClipManifestClip): string {
|
|||||||
|
|
||||||
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
|
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
|
||||||
// walks the whole tree, and a parse touches every clip in it.
|
// walks the whole tree, and a parse touches every clip in it.
|
||||||
const groupInfoCache = new WeakMap<Document, Map<string, { label: string; volume: number }>>();
|
const groupInfoCache = new WeakMap<
|
||||||
|
Document,
|
||||||
|
Map<string, { label: string; volume: number; hidden: boolean }>
|
||||||
|
>();
|
||||||
|
|
||||||
function groupInfoFor(
|
function groupInfoFor(
|
||||||
doc: Document | null | undefined,
|
doc: Document | null | undefined,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
): { label: string; volume: number } {
|
): { label: string; volume: number; hidden: boolean } {
|
||||||
if (!doc) return { label: groupId, volume: 1 };
|
if (!doc) return { label: groupId, volume: 1, hidden: false };
|
||||||
let info = groupInfoCache.get(doc);
|
let info = groupInfoCache.get(doc);
|
||||||
if (!info) {
|
if (!info) {
|
||||||
info = new Map(
|
info = new Map(
|
||||||
resolveAudioGroups(doc).map((group) => [
|
resolveAudioGroups(doc).map((group) => [
|
||||||
group.id,
|
group.id,
|
||||||
{ label: group.label, volume: group.volume },
|
{ label: group.label, volume: group.volume, hidden: group.hidden },
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
groupInfoCache.set(doc, info);
|
groupInfoCache.set(doc, info);
|
||||||
}
|
}
|
||||||
return info.get(groupId) ?? { label: groupId, volume: 1 };
|
return info.get(groupId) ?? { label: groupId, volume: 1, hidden: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
@@ -167,6 +170,7 @@ export function createTimelineElementFromManifestClip(params: {
|
|||||||
const info = groupInfoFor(doc ?? hostEl.ownerDocument, audioGroup);
|
const info = groupInfoFor(doc ?? hostEl.ownerDocument, audioGroup);
|
||||||
entry.audioGroupLabel = info.label;
|
entry.audioGroupLabel = info.label;
|
||||||
entry.audioGroupVolume = info.volume;
|
entry.audioGroupVolume = info.volume;
|
||||||
|
entry.audioGroupHidden = info.hidden;
|
||||||
}
|
}
|
||||||
const fxChain = hostEl.getAttribute("data-fx-chain");
|
const fxChain = hostEl.getAttribute("data-fx-chain");
|
||||||
if (fxChain) entry.fxChain = fxChain;
|
if (fxChain) entry.fxChain = fxChain;
|
||||||
@@ -392,6 +396,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|||||||
const domGroupInfo = groupInfoFor(doc, domAudioGroup);
|
const domGroupInfo = groupInfoFor(doc, domAudioGroup);
|
||||||
entry.audioGroupLabel = domGroupInfo.label;
|
entry.audioGroupLabel = domGroupInfo.label;
|
||||||
entry.audioGroupVolume = domGroupInfo.volume;
|
entry.audioGroupVolume = domGroupInfo.volume;
|
||||||
|
entry.audioGroupHidden = domGroupInfo.hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sub-compositions
|
// Sub-compositions
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { usePlayerStore } from "./playerStore";
|
||||||
|
import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "./audioSoloSlice";
|
||||||
|
import * as studioFileHistory from "../../utils/studioFileHistory";
|
||||||
|
|
||||||
|
describe("audioSoloSlice", () => {
|
||||||
|
it("is exclusive by default, and clicking the only soloed element again clears it", () => {
|
||||||
|
usePlayerStore.getState().toggleSolo("a");
|
||||||
|
expect(usePlayerStore.getState().soloed).toEqual(new Set(["a"]));
|
||||||
|
usePlayerStore.getState().toggleSolo("b");
|
||||||
|
expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
|
||||||
|
usePlayerStore.getState().toggleSolo("b");
|
||||||
|
expect(usePlayerStore.getState().soloed).toEqual(new Set());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("⌘/Ctrl-click adds and removes membership without disturbing the rest", () => {
|
||||||
|
usePlayerStore.getState().toggleSolo("a");
|
||||||
|
usePlayerStore.getState().toggleSolo("b", { add: true });
|
||||||
|
expect(usePlayerStore.getState().soloed).toEqual(new Set(["a", "b"]));
|
||||||
|
usePlayerStore.getState().toggleSolo("a", { add: true });
|
||||||
|
expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearSolo empties the set", () => {
|
||||||
|
usePlayerStore.getState().toggleSolo("a");
|
||||||
|
usePlayerStore.getState().clearSolo();
|
||||||
|
expect(usePlayerStore.getState().soloed).toEqual(new Set());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("export-safety: solo never touches an attribute or the save path", () => {
|
||||||
|
it("toggling, ⌘-adding, and clearing solo never calls setAttribute/removeAttribute on any element", () => {
|
||||||
|
usePlayerStore.getState().clearSolo();
|
||||||
|
const setAttributeSpy = vi.spyOn(Element.prototype, "setAttribute");
|
||||||
|
const removeAttributeSpy = vi.spyOn(Element.prototype, "removeAttribute");
|
||||||
|
|
||||||
|
usePlayerStore.getState().toggleSolo("a");
|
||||||
|
usePlayerStore.getState().toggleSolo("b", { add: true });
|
||||||
|
usePlayerStore.getState().toggleSolo("a", { add: true });
|
||||||
|
usePlayerStore.getState().clearSolo();
|
||||||
|
|
||||||
|
expect(setAttributeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(removeAttributeSpy).not.toHaveBeenCalled();
|
||||||
|
setAttributeSpy.mockRestore();
|
||||||
|
removeAttributeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggling, ⌘-adding, and clearing solo never invokes the project save path (setElementsHidden's own write function)", () => {
|
||||||
|
usePlayerStore.getState().clearSolo();
|
||||||
|
const saveSpy = vi.spyOn(studioFileHistory, "saveProjectFilesWithHistory");
|
||||||
|
|
||||||
|
usePlayerStore.getState().toggleSolo("a");
|
||||||
|
usePlayerStore.getState().toggleSolo("b", { add: true });
|
||||||
|
usePlayerStore.getState().clearSolo();
|
||||||
|
|
||||||
|
expect(saveSpy).not.toHaveBeenCalled();
|
||||||
|
saveSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAudibleUnderSolo — the three-bullet rule", () => {
|
||||||
|
it("everything is audible when no solo is active", () => {
|
||||||
|
expect(isAudibleUnderSolo(new Set(), "clip-a")).toBe(true);
|
||||||
|
expect(isAudibleUnderSolo(new Set(), "clip-a", "group-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a soloed element is audible", () => {
|
||||||
|
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-a")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a sibling of a soloed element is silent", () => {
|
||||||
|
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a member of a soloed group is audible (group solo = members solo)", () => {
|
||||||
|
expect(isAudibleUnderSolo(new Set(["group-1"]), "clip-a", "group-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a member of an UNsoloed group, while a sibling group is soloed, is silent", () => {
|
||||||
|
expect(isAudibleUnderSolo(new Set(["group-2"]), "clip-a", "group-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("soloing one member of a group does not un-attenuate its siblings in the same group", () => {
|
||||||
|
// clip-a is soloed directly; clip-b shares its group but is not itself
|
||||||
|
// soloed and the group itself is not soloed — clip-b stays silent.
|
||||||
|
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b", "group-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the group itself, monitored as a bus, is audible only when it or a member is soloed", () => {
|
||||||
|
expect(isAudibleUnderSolo(new Set(["clip-a"]), "group-1", null)).toBe(false);
|
||||||
|
expect(isAudibleUnderSolo(new Set(["group-1"]), "group-1", null)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isGroupHalfLitUnderSolo", () => {
|
||||||
|
it("is false when no solo is active", () => {
|
||||||
|
expect(isGroupHalfLitUnderSolo(new Set(), "group-1", ["a", "b"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when the group itself is soloed (fully lit, not half)", () => {
|
||||||
|
expect(isGroupHalfLitUnderSolo(new Set(["group-1"]), "group-1", ["a", "b"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when a member is soloed but the group is not", () => {
|
||||||
|
expect(isGroupHalfLitUnderSolo(new Set(["a"]), "group-1", ["a", "b"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when an unrelated element is soloed", () => {
|
||||||
|
expect(isGroupHalfLitUnderSolo(new Set(["other"]), "group-1", ["a", "b"])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* "Hear only this" — session-only monitoring override, never persisted.
|
||||||
|
*
|
||||||
|
* Holds clip ids and group ids (never track numbers, per the design doc: solo
|
||||||
|
* is a per-element/per-group concept, not a row-position one). Exclusive by
|
||||||
|
* default (a plain toggle replaces the set); ⌘/Ctrl-click adds/removes one
|
||||||
|
* member without disturbing the rest. Never written to any attribute or
|
||||||
|
* document — the export-safety guarantee this slice exists to hold.
|
||||||
|
*/
|
||||||
|
import type { StoreApi } from "zustand";
|
||||||
|
import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "@hyperframes/core/audio-groups";
|
||||||
|
|
||||||
|
export { isAudibleUnderSolo, isGroupHalfLitUnderSolo };
|
||||||
|
|
||||||
|
export interface AudioSoloSlice {
|
||||||
|
soloed: ReadonlySet<string>;
|
||||||
|
toggleSolo: (id: string, options?: { add?: boolean }) => void;
|
||||||
|
clearSolo: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAudioSoloSlice(
|
||||||
|
set: StoreApi<AudioSoloSlice>["setState"],
|
||||||
|
get: StoreApi<AudioSoloSlice>["getState"],
|
||||||
|
): AudioSoloSlice {
|
||||||
|
return {
|
||||||
|
soloed: new Set(),
|
||||||
|
toggleSolo: (id, options) => {
|
||||||
|
const current = get().soloed;
|
||||||
|
if (options?.add) {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
set({ soloed: next });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Exclusive click: soloing the only-soloed element again clears it;
|
||||||
|
// otherwise it replaces the set.
|
||||||
|
const isOnlyMember = current.size === 1 && current.has(id);
|
||||||
|
set({ soloed: isOnlyMember ? new Set() : new Set([id]) });
|
||||||
|
},
|
||||||
|
clearSolo: () => set({ soloed: new Set() }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "./automationSelectionSlice";
|
} from "./automationSelectionSlice";
|
||||||
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
|
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
|
||||||
import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
|
import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
|
||||||
|
import { createAudioSoloSlice, type AudioSoloSlice } from "./audioSoloSlice";
|
||||||
|
|
||||||
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
||||||
export { liveTime } from "./liveTime";
|
export { liveTime } from "./liveTime";
|
||||||
@@ -47,7 +48,8 @@ function resolveElementSelection(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice {
|
interface PlayerState
|
||||||
|
extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice, AudioSoloSlice {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
@@ -329,6 +331,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
...createThumbnailSlice(set),
|
...createThumbnailSlice(set),
|
||||||
|
|
||||||
...createAutomationSelectionSlice(set),
|
...createAutomationSelectionSlice(set),
|
||||||
|
...createAudioSoloSlice(set, get),
|
||||||
|
|
||||||
activeKeyframePct: null,
|
activeKeyframePct: null,
|
||||||
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export interface TimelineElement {
|
|||||||
audioGroupLabel?: string;
|
audioGroupLabel?: string;
|
||||||
/** The owning group's `data-volume` (defaults to 1) — resolved once per parse. */
|
/** The owning group's `data-volume` (defaults to 1) — resolved once per parse. */
|
||||||
audioGroupVolume?: number;
|
audioGroupVolume?: number;
|
||||||
|
/** The owning group's `data-hidden` (defaults to false) — resolved once per parse. */
|
||||||
|
audioGroupHidden?: boolean;
|
||||||
/**
|
/**
|
||||||
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
||||||
* child: the absolute master-timeline start of the sub-comp host the child
|
* child: the absolute master-timeline start of the sub-comp host the child
|
||||||
|
|||||||
Reference in New Issue
Block a user