mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +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
+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 { 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<BlockPreviewInfo | null>(null);
|
||||
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
useAudioSoloBridge(previewIframeRef);
|
||||
const activeCompPathRef = useRef(activeCompPath);
|
||||
activeCompPathRef.current = activeCompPath;
|
||||
const leftSidebarRef = useRef<LeftSidebarHandle>(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,
|
||||
|
||||
Reference in New Issue
Block a user