diff --git a/packages/core/src/audioGroups.ts b/packages/core/src/audioGroups.ts index a69d34ed4a..5fc6fb49ae 100644 --- a/packages/core/src/audioGroups.ts +++ b/packages/core/src/audioGroups.ts @@ -127,3 +127,35 @@ export function audioGroupOf(el: Element): string | null { if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return 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, + 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, + groupId: string, + memberIds: readonly string[], +): boolean { + if (soloed.size === 0 || soloed.has(groupId)) return false; + return memberIds.some((id) => soloed.has(id)); +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index d2af213195..bc92a4e0c0 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -41,6 +41,7 @@ import { applyVariableBindings } from "./applyVariableBindings"; import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading"; import { TransportClock } from "./clock"; import { WebAudioTransport } from "./webAudioTransport"; +import { HF_AUDIO_GROUP_TAG, audioGroupOf, isAudibleUnderSolo } from "../audioGroups"; import { quantizeTimeToFrame } from "../inline-scripts/parityContract"; import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers"; import type { @@ -175,6 +176,20 @@ export function initSandboxRuntimeModular(): void { void webAudio.init().then((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 = 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 // 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 — @@ -1925,6 +1940,21 @@ export function initSandboxRuntimeModular(): void { const nodeAffectsAudio = (node: HTMLElement): boolean => node.matches("audio[data-start]") || node.querySelector("audio[data-start]") !== null; + // An `` 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(); + 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 = ( currentTime: number, visibilityNodes: Element[] = Array.from(document.querySelectorAll("[data-start]")), @@ -1989,6 +2019,7 @@ export function initSandboxRuntimeModular(): void { scheduleWebAudioForActiveClips(); } hiddenAudioDirty = false; + syncAudioGroupMute(); }; const syncMediaForCurrentState = () => { @@ -2054,6 +2085,7 @@ export function initSandboxRuntimeModular(): void { forceSync, onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume), isWebAudioOwned: (el) => webAudio.ownsElement(el), + isAudibleUnderSolo: (el) => isAudibleUnderSolo(soloedIds, el.id, audioGroupOf(el)), onAutoplayBlocked: () => { if (state.mediaAutoplayBlockedPosted) return; state.mediaAutoplayBlockedPosted = true; diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 72df53382e..d0a43c0edb 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -226,6 +226,11 @@ export function syncRuntimeMedia(params: { * plays it); not owned → leave audible (HTMLMedia fallback). Per-element, not a * global flag, so a not-yet-claimed track isn't muted by other tracks. */ isWebAudioOwned?: (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; }): void { const forceMuteAll = !!(params.outputMuted || params.userMuted); @@ -312,7 +317,11 @@ export function syncRuntimeMedia(params: { // A data-hidden ancestor is silent in the export (audioMixer.ts drops // it); preview must match. Folded into the per-tick volume, not // 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; lastRuntimeAppliedVolume.set(el, effectiveVolume); params.onElementVolume?.(el, effectiveVolume); diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 598a6bdf9c..c16aa9a854 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -527,23 +527,26 @@ describe("WebAudioTransport", () => { return el; } - /** The group's own input gain is built lazily on the first member — - * index 1 in creation order (that member's gain is index 0). */ + /** The group's own input gain is built lazily on the first member — index + * 2 in creation order (that member's own gain is 0, its solo gain 1). */ const firstGroupInput = (mock: ReturnType) => - mock.gainNodes[1]!; + mock.gainNodes[2]!; beforeEach(() => { 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(); await scheduleGrouped(transport, gen, "solo"); - // One gain node — the member's own — connected directly to master. - expect(mock.gainNodes).toHaveLength(1); - expect(mock.gainNodes[0]!.connect).toHaveBeenCalledWith(mock.masterGain); + // Member gain, then its dedicated solo gain (B5) — never straight to master. + expect(mock.gainNodes).toHaveLength(2); + 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 () => { @@ -552,26 +555,33 @@ describe("WebAudioTransport", () => { await scheduleGrouped(transport, gen, "a", "vo"); await scheduleGrouped(transport, gen, "b", "vo"); - // Member gain nodes: index 0 (a) and index 3 (b) — index 1/2 are the - // group's own input/output gain pair (B7's meter taps `output`), - // built inside a's schedule call. - expect(mock.gainNodes.length).toBeGreaterThanOrEqual(4); - const groupInput = firstGroupInput(mock); - const groupOutput = mock.gainNodes[2]!; + // Creation order for a: a-gain(0), a-solo(1), groupInput(2), groupOutput(3), + // muteGain(4) — the group bus is built lazily inside a's schedule call. + // Then b: b-gain(5), b-solo(6). + expect(mock.gainNodes.length).toBeGreaterThanOrEqual(7); const aGain = mock.gainNodes[0]!; - const bGain = mock.gainNodes[3]!; - - // Neither member connects straight to master — both feed the shared bus. - expect(aGain.connect).toHaveBeenCalledWith(groupInput); - expect(bGain.connect).toHaveBeenCalledWith(groupInput); - expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain); - expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain); - - // The bus's input never reaches master directly — it lands on the - // output gain (the dry passthrough, since neither member's group has a - // chain-bearing ``), and THAT reaches master. + 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]!; + + // Each member feeds its own solo gain, and both solo gains feed the + // shared bus — neither connects straight to master. + expect(aGain.connect).toHaveBeenCalledWith(aSolo); + expect(bGain.connect).toHaveBeenCalledWith(bSolo); + 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 mute + // gain (B5) first (the dry passthrough, since neither member's group has + // a chain-bearing ``), then the output gain, then master. 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); }); @@ -579,11 +589,11 @@ describe("WebAudioTransport", () => { const { transport, mock, gen } = setupGroupTransport(); 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"); - // Only b's own gain is new — no second group-input gain minted. - expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 1); + // Only b's own gain and its solo gain are new — no second group bus minted. + expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 2); }); it("a group id with no matching element still gets a flat bus", async () => { @@ -591,8 +601,10 @@ describe("WebAudioTransport", () => { await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element - const groupOutput = mock.gainNodes[2]!; - expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(groupOutput); + const muteGain = mock.gainNodes[4]!; + const groupOutput = mock.gainNodes[3]!; + expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(muteGain); + expect(muteGain.connect).toHaveBeenCalledWith(groupOutput); expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain); }); @@ -629,6 +641,106 @@ describe("WebAudioTransport", () => { 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 = ``; + 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)", () => { it("groupLevel returns null for an unknown/idle group id", () => { const { transport } = setupGroupTransport(); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index 698bda00de..e683fb23ad 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -5,7 +5,7 @@ import { type AutomationTiming, } from "../audio/audioFxAutomation.js"; import { VOLUME_RANGE } from "../audioAutomation.js"; -import { audioGroupOf } from "../audioGroups.js"; +import { audioGroupOf, isAudibleUnderSolo } from "../audioGroups.js"; import { swallow } from "./diagnostics"; import { getDebugSurface } from "./globals.js"; @@ -85,6 +85,11 @@ export type ScheduledSource = { el: HTMLMediaElement; sourceNode: AudioBufferSourceNode; 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?: ElementFxHandle | null; compositionStart: number; @@ -109,7 +114,13 @@ export class WebAudioTransport { // a group does not rebuild its chain; only `destroy()` disposes these. private _groups = new Map< 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 was `_rateAnchorComp`, and time has been advancing at @@ -119,6 +130,10 @@ export class WebAudioTransport { private _rate = 1; private _paused = true; 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 = new Set(); async init(): Promise { try { @@ -210,10 +225,10 @@ export class WebAudioTransport { // Stable point the FX chain (or, when there's none, the dry passthrough — // see `attachElementFxChain`'s `detach()`) always lands on before master, // 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` - // (between the FX chain and here), never after — the meter is defined to - // read the group's true, honestly-muted level (design doc §5), and this - // node is that contract's anchor. + // meter taps here. The mute gain splices in BEFORE `output` (between the + // FX chain and here), never after — the meter is defined to read the + // group's true, honestly-muted level (design doc §5), and this node is + // that contract's anchor. const output = this._ctx.createGain(); output.connect(this._masterGain); const analyser = this._ctx.createAnalyser(); @@ -221,23 +236,28 @@ export class WebAudioTransport { output.connect(analyser); 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( this._ctx, groupEl ?? { getAttribute: () => null }, input, - output, + muteGain, timing, ); if (groupEl) scheduleVolumeLane(groupEl, input, timing); this._groups.set(groupId, { input, + muteGain, analyser, levelBuf: new Float32Array(analyser.fftSize), dispose: () => { try { fx?.dispose(); input.disconnect(); + muteGain.disconnect(); output.disconnect(); analyser.disconnect(); } catch { @@ -248,6 +268,24 @@ export class WebAudioTransport { 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` — * a group with no active member yet has no entry here). */ groupIds(): string[] { @@ -311,6 +349,7 @@ export class WebAudioTransport { sourceNode.disconnect(); scheduled.fx?.dispose(); scheduled.gainNode.disconnect(); + scheduled.soloGain.disconnect(); } catch { // Already torn down. } @@ -361,7 +400,10 @@ export class WebAudioTransport { // 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. 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, ); @@ -384,6 +426,7 @@ export class WebAudioTransport { sourceNode.disconnect(); fx?.dispose(); gainNode.disconnect(); + soloGain.disconnect(); return null; } @@ -396,6 +439,7 @@ export class WebAudioTransport { el, sourceNode, gainNode, + soloGain, compositionStart, mediaStart, scheduledAt, @@ -463,6 +507,7 @@ export class WebAudioTransport { source.sourceNode.disconnect(); source.fx?.dispose(); source.gainNode.disconnect(); + source.soloGain.disconnect(); } catch { // already stopped } @@ -496,6 +541,31 @@ export class WebAudioTransport { } } + /** + * 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): 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 { return this._activeSources.length > 0 && !this._paused; } diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 68a93ebb46..c94addfcb3 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -37,6 +37,12 @@ declare global { onSwallowed?: (label: string, err: unknown) => void; seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void; 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; __renderReady?: boolean; diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index cac9b10ae8..328b943c22 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -1107,6 +1107,18 @@ describe("parseAudioElements — hidden tracks", () => { "visible-video-audio", ]); }); + + it("excludes every member of a hidden group, even though the members carry no data-hidden of their own", () => { + const html = + `
` + + `` + + `` + + `` + + `` + + `
`; + + expect(parseAudioElements(html).map((track) => track.id)).toEqual(["master"]); + }); }); describe("parseAudioElements data-fx-chain", () => { diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b319ca6c73..03718410d9 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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 { useRenderQueue } from "./components/renders/useRenderQueue"; import { usePlayerStore } from "./player"; @@ -38,6 +38,7 @@ import { useToast } from "./hooks/useToast"; import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader"; import { useStudioUrlState } from "./hooks/useStudioUrlState"; import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration"; +import { useAudioSoloBridge } from "./hooks/useAudioSoloBridge"; import { buildStudioContextValue, useGlobalFileDrop, @@ -61,11 +62,8 @@ import { StudioSplash } from "./components/StudioSplash"; import { useServerConnection } from "./hooks/useServerConnection"; import { useStudioSessionStart } from "./hooks/useStudioSessionStart"; import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead"; -import { - normalizeStudioCompositionPath, - readStudioUrlStateFromWindow, - resolveMasterCompositionPath, -} from "./utils/studioUrlState"; +import { readStudioUrlStateFromWindow, resolveMasterCompositionPath } from "./utils/studioUrlState"; +import { useHydrateActiveCompPathFromUrl } from "./hooks/useHydrateActiveCompPathFromUrl"; const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds; // fallow-ignore-next-line complexity export function StudioApp() { @@ -84,6 +82,7 @@ export function StudioApp() { const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion(); const [blockPreview, setBlockPreview] = useState(null); const previewIframeRef = useRef(null); + useAudioSoloBridge(previewIframeRef); const activeCompPathRef = useRef(activeCompPath); activeCompPathRef.current = activeCompPath; const leftSidebarRef = useRef(null); @@ -127,16 +126,14 @@ export function StudioApp() { activeCompPath, masterCompPath, ); - useEffect(() => { - if (activeCompPathHydrated) return; - if (!fileManager.fileTreeLoaded) return; - const nextCompPath = normalizeStudioCompositionPath( - initialUrlStateRef.current.activeCompPath, - fileManager.fileTree, - ); - setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath)); - setActiveCompPathHydrated(true); - }, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]); + useHydrateActiveCompPathFromUrl({ + hydrated: activeCompPathHydrated, + fileTreeLoaded: fileManager.fileTreeLoaded, + fileTree: fileManager.fileTree, + initialUrlStateRef, + setActiveCompPath, + setHydrated: setActiveCompPathHydrated, + }); const previewPersistence = usePreviewPersistence({ showToast, readOptionalProjectFile: fileManager.readOptionalProjectFile, diff --git a/packages/studio/src/components/nle/PreviewPane.tsx b/packages/studio/src/components/nle/PreviewPane.tsx index 530f4ba95f..42c6e742f2 100644 --- a/packages/studio/src/components/nle/PreviewPane.tsx +++ b/packages/studio/src/components/nle/PreviewPane.tsx @@ -156,6 +156,7 @@ export function PreviewPane({ disabled={timelineDisabled} isFullscreen={isFullscreen} onToggleFullscreen={toggleFullscreen} + previewIframeRef={iframeRef} /> diff --git a/packages/studio/src/hooks/useAudioSoloBridge.ts b/packages/studio/src/hooks/useAudioSoloBridge.ts new file mode 100644 index 0000000000..da790c5142 --- /dev/null +++ b/packages/studio/src/hooks/useAudioSoloBridge.ts @@ -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]); +} diff --git a/packages/studio/src/hooks/useHydrateActiveCompPathFromUrl.ts b/packages/studio/src/hooks/useHydrateActiveCompPathFromUrl.ts new file mode 100644 index 0000000000..f52963dcdd --- /dev/null +++ b/packages/studio/src/hooks/useHydrateActiveCompPathFromUrl.ts @@ -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; + 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]); +} diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index 1ac690617b..e55469c95f 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -6,6 +6,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore"; import { trackStudioEvent } from "../../utils/studioTelemetry"; import { Tooltip } from "../../components/ui"; import { useMountEffect } from "../../hooks/useMountEffect"; +import { useSoloBannerText } from "../../hooks/useAudioSoloBridge"; import { ShortcutsPanel } from "./ShortcutsPanel"; import { SpeedMenu } from "./SpeedMenu"; @@ -206,6 +207,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 ( +
+ + Hearing only {bannerText} — your + export is not affected + + +
+ ); +}); + /* ── Main component ──────────────────────────────────────────────── */ interface PlayerControlsProps { @@ -214,6 +243,7 @@ interface PlayerControlsProps { disabled?: boolean; isFullscreen?: boolean; onToggleFullscreen?: () => void; + previewIframeRef?: { current: HTMLIFrameElement | null }; } export const PlayerControls = memo(function PlayerControls({ @@ -222,6 +252,7 @@ export const PlayerControls = memo(function PlayerControls({ disabled = false, isFullscreen = false, onToggleFullscreen, + previewIframeRef, }: PlayerControlsProps) { const isPlaying = usePlayerStore((s) => s.isPlaying); const duration = usePlayerStore((s) => s.duration); @@ -271,71 +302,78 @@ export const PlayerControls = memo(function PlayerControls({ }); return ( -
- + {previewIframeRef && } +
- - + + - - - + + + -
- - - - {onToggleFullscreen && ( - - )} - +
+ + + + {onToggleFullscreen && ( + + )} + +
); diff --git a/packages/studio/src/player/components/TimelineGroupHeader.tsx b/packages/studio/src/player/components/TimelineGroupHeader.tsx index fcdcd8f961..17a9b9aa95 100644 --- a/packages/studio/src/player/components/TimelineGroupHeader.tsx +++ b/packages/studio/src/player/components/TimelineGroupHeader.tsx @@ -1,3 +1,4 @@ +import { SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react"; import { TRACK_H } from "./timelineLayout"; import type { TimelineTheme } from "./timelineTheme"; @@ -11,14 +12,23 @@ interface TimelineGroupHeaderProps { laneCount: number; isLaneOpen: boolean; 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; theme: TimelineTheme; } /** - * A group's own row header: caret (member disclosure) + `▤` + label + `∿ n` - * (lane disclosure). Mute/solo (B5) and the FX entry point (C1) land here as - * siblings once those steps exist — nothing to reserve for them yet. + * A group's own row header: caret (member disclosure) + `▤` + label + count + + * mute + solo + `∿ n` (lane disclosure). The FX entry point (C1) lands here + * as a sibling once that step exists. */ export function TimelineGroupHeader({ label, @@ -28,6 +38,11 @@ export function TimelineGroupHeader({ laneCount, isLaneOpen, onToggleLanes, + hidden, + onToggleHidden, + isSoloed, + isHalfLitSolo, + onToggleSolo, columnWidth, theme, }: TimelineGroupHeaderProps) { @@ -76,6 +91,47 @@ export function TimelineGroupHeader({ > {memberCount} + + + ); +} diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 44f0d475f1..61dba648a6 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -1,15 +1,12 @@ -import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { isCanaryEnabled } from "../../telemetry/canary"; -import { Music } from "../../icons/SystemIcons"; -import type { TimelineElement } from "../store/playerStore"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; import { groupAutomationLanes } from "./automationLaneData"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { clipTimingStart } from "../../hooks/gsapShared"; import { LayerDisclosureRow } from "./LayerDisclosureRow"; -import { TrackClipCount } from "./TrackClipCount"; import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout"; import type { TimelineTheme } from "./timelineTheme"; import { @@ -62,109 +59,6 @@ interface TimelineTrackHeaderProps { 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